19 Commits

Author SHA1 Message Date
b27a734e3c Merge pull request 'feat: add translator cog for fantasy languages' (#9) from translator into main
Reviewed-on: #9
2025-09-21 04:28:34 -04:00
9ad286b671 Update translator/translator.py
Unicode Error Fix
2025-09-21 04:27:45 -04:00
b186a9c119 Merge pull request 'feat: add translator cog for fantasy languages' (#10) from translator into main
Reviewed-on: #10
2025-09-21 04:24:11 -04:00
d3ee48112a feat: add translator cog for fantasy languages
Implements commands for translating text into roleplay languages like Valspiren, Elvish, and Draconic, ported from a web application.
2025-09-21 04:22:21 -04:00
48c3793768 refactor: rename directory to kofishop 2025-09-20 21:14:42 -04:00
141efc4253 Update info.json 2025-09-20 21:02:18 -04:00
61b81068ba Update kofi-shop/kofishop.py 2025-09-20 20:58:46 -04:00
a0df694243 Update kofi-shop/__init__.py 2025-09-20 20:58:17 -04:00
ca7dba5af6 Merge pull request 'feat: Add Hiring cog for ticket-based applications' (#8) from hiring into main
Reviewed-on: #8
2025-09-20 20:49:41 -04:00
d65197e552 Merge pull request 'feat: implement KofiShop cog for Discord bot integration' (#7) from kofishop into main
Reviewed-on: #7
2025-09-20 20:48:58 -04:00
0909717fb6 Merge pull request 'feat: add configurable forum-based ModMail system' (#6) from modmail into main
Reviewed-on: #6
2025-09-20 20:48:38 -04:00
84a2b41a79 feat: Add Hiring cog for ticket-based applications
Introduces a Discord cog to manage staff and PM hiring through interactive tickets, forms, and buttons. Includes modals for Staff, PM, and HPM applications, as well as commands for posting hiring messages and configuring categories and channels.
2025-09-20 20:47:25 -04:00
82e48c2383 feat: implement KofiShop cog for Discord bot integration
Introduces a new cog to manage Ko-fi shop orders, reviews, and waitlists.
Users can submit orders and reviews via interactive modals.
Administrators can configure channels and add users to the waitlist.
2025-09-20 20:46:32 -04:00
2d199d9247 feat: add configurable forum-based ModMail system
Introduces a new cog that handles user DMs by creating threads in a designated forum channel.

Enables staff to reply in threads to send messages back to users anonymously.

Includes commands to configure settings, enable/disable the system, and close threads.

Improves moderation by streamlining ticket management in Discord forums.
2025-09-20 20:45:15 -04:00
f546eaa633 Merge pull request 'feat: implement configurable welcomer cog with settings' (#5) from welcomer into main
Reviewed-on: #5
2025-09-11 19:53:02 -04:00
e0330148c2 Merge pull request 'feat: add welcomer cog for greeting new members' (#4) from welcomer into main
Reviewed-on: #4
2025-09-11 17:30:02 -04:00
3955e61a62 Merge pull request 'iservice' (#3) from iservice into main
Reviewed-on: #3
2025-09-11 17:29:20 -04:00
a3e210a7ce Merge pull request 'feat: add initial files for KofiShop cog' (#2) from kofishop into main
Reviewed-on: #2
2025-09-11 17:29:02 -04:00
5fd4e08d90 Merge pull request 'kbump' (#1) from kbump into main
Reviewed-on: #1
2025-09-11 17:28:31 -04:00
74 changed files with 794 additions and 2688 deletions

2
.gitignore vendored
View File

@@ -179,5 +179,3 @@ cython_debug/
.vs/ .vs/
pyproject.toml pyproject.toml
treegen.py

View File

@@ -1,4 +0,0 @@
from .datamanager import DataManager
async def setup(bot):
await bot.add_cog(DataManager(bot))

View File

@@ -1,128 +0,0 @@
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))

View File

@@ -1,17 +0,0 @@
{
"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."
}

4
hiring/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .hiring import setup
# This function is required for the cog to be loaded by Red.
# It simply imports the setup function from the main cog file.

View File

@@ -1,10 +1,10 @@
import discord import discord
from redbot.core import commands, Config, app_commands from redbot.core import commands, Config
from typing import Literal, Optional, TYPE_CHECKING, Type from redbot.core.bot import Red
import datetime from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from redbot.core.bot import Red from hiring.hiring import Hiring
# --- Modals for the Application Forms --- # --- Modals for the Application Forms ---
@@ -12,252 +12,242 @@ class StaffApplicationModal(discord.ui.Modal, title="Staff Application"):
chosen_plan = discord.ui.TextInput(label="What plan are you interested in?") chosen_plan = discord.ui.TextInput(label="What plan are you interested in?")
tox_level = discord.ui.TextInput(label="What is your toxicity level tolerance?") 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) describe_server = discord.ui.TextInput(label="Please describe your server.", style=discord.TextStyle.paragraph)
server_link = discord.ui.TextInput(label="Server Invite Link") server_link = discord.ui.TextInput(label="Server Link")
def __init__(self, ticket_channel: discord.TextChannel):
super().__init__()
self.ticket_channel = ticket_channel
async def on_submit(self, interaction: discord.Interaction): async def on_submit(self, interaction: discord.Interaction):
cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore embed = discord.Embed(
if not cog: title="New Staff Application",
await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) description=f"Submitted by {interaction.user.mention}",
return color=0xadd8e6
)
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="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="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 Description", value=self.describe_server.value, inline=False)
embed.add_field(name="Server Link", value=self.server_link.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) await interaction.response.send_message("Your application has been submitted!", ephemeral=True)
if isinstance(interaction.channel, discord.TextChannel):
await interaction.channel.send(embed=embed)
class PMApplicationModal(discord.ui.Modal, title="PM Application"): class PMApplicationModal(discord.ui.Modal, title="PM Application"):
ad = discord.ui.TextInput(label="Your Ad", style=discord.TextStyle.paragraph) ad = discord.ui.TextInput(label="Please provide your server ad.", style=discord.TextStyle.paragraph)
reqs = discord.ui.TextInput(label="Your Requirements", style=discord.TextStyle.paragraph) reqs = discord.ui.TextInput(label="What are your requirements?")
tox_level = discord.ui.TextInput(label="What is your toxicity level tolerance?") tox_level = discord.ui.TextInput(label="What is your toxicity level tolerance?")
chosen_plan = discord.ui.TextInput(label="What plan are you interested in?") 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): async def on_submit(self, interaction: discord.Interaction):
cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore embed = discord.Embed(
if not cog: title="New PM Application",
await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) description=f"Submitted by {interaction.user.mention}",
return color=0xadd8e6
)
embed = discord.Embed(title="New PM Application", color=discord.Color.green()) embed.add_field(name="Server Ad", value=f"```\n{self.ad.value}\n```", inline=False)
embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) embed.add_field(name="Requirements", value=self.reqs.value, inline=False)
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="Toxicity Level", value=self.tox_level.value, inline=False)
embed.add_field(name="Chosen Plan", value=self.chosen_plan.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 application has been submitted!", ephemeral=True)
await interaction.response.send_message("Your PM application has been submitted.", ephemeral=True) if isinstance(interaction.channel, discord.TextChannel):
await interaction.channel.send(embed=embed)
class HPMApplicationModal(discord.ui.Modal, title="HPM Application"): class HPMApplicationModal(discord.ui.Modal, title="HPM Application"):
ad = discord.ui.TextInput(label="Your Ad", style=discord.TextStyle.paragraph) ad = discord.ui.TextInput(label="Please provide your server ad.", style=discord.TextStyle.paragraph)
reqs = discord.ui.TextInput(label="Your Requirements", style=discord.TextStyle.paragraph) reqs = discord.ui.TextInput(label="What are your requirements?")
def __init__(self, ticket_channel: discord.TextChannel):
super().__init__()
self.ticket_channel = ticket_channel
async def on_submit(self, interaction: discord.Interaction): async def on_submit(self, interaction: discord.Interaction):
cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore embed = discord.Embed(
if not cog: title="New HPM Application",
await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) description=f"Submitted by {interaction.user.mention}",
color=0xadd8e6
)
embed.add_field(name="Server Ad", value=f"```\n{self.ad.value}\n```", inline=False)
embed.add_field(name="Requirements", value=self.reqs.value, inline=False)
await interaction.response.send_message("Your application has been submitted!", ephemeral=True)
if isinstance(interaction.channel, discord.TextChannel):
await interaction.channel.send(embed=embed)
# --- Reusable Ticket Creation Logic ---
async def create_ticket(interaction: discord.Interaction, role_type: str, category_id: Optional[int], modal: discord.ui.Modal):
if not interaction.guild:
await interaction.response.send_message("This interaction must be used in a server.", ephemeral=True)
return return
embed = discord.Embed(title="New HPM Application", color=discord.Color.purple()) if not category_id:
embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) await interaction.response.send_message(f"The category for '{role_type}' applications has not been set by an admin.", ephemeral=True)
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 return
guild = interaction.guild category = interaction.guild.get_channel(category_id)
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): if not isinstance(category, discord.CategoryChannel):
await interaction.response.send_message(f"The configured category for '{ticket_type}' is invalid.", ephemeral=True) await interaction.response.send_message(f"The category for '{role_type}' applications is invalid or has been deleted.", ephemeral=True)
return return
assert isinstance(interaction.user, discord.Member) ticket_name = f"{role_type}-app-{interaction.user.name}"
try: try:
thread_name = f"{ticket_type}-application-{interaction.user.name}"
overwrites = { overwrites = {
guild.default_role: discord.PermissionOverwrite(read_messages=False), interaction.guild.default_role: discord.PermissionOverwrite(read_messages=False),
interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True) interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True),
interaction.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True)
} }
ticket_channel = await category.create_text_channel(name=thread_name, overwrites=overwrites) ticket_channel = await interaction.guild.create_text_channel(
name=ticket_name,
modal_instance = modal_class(ticket_channel) # type: ignore category=category,
await interaction.response.send_modal(modal_instance) overwrites=overwrites
)
async with cog.config.guild(guild).closed_applications() as closed_apps: await ticket_channel.send(f"Welcome {interaction.user.mention}! Please fill out the form to complete your application.")
closed_apps[str(ticket_channel.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() await interaction.response.send_modal(modal)
except discord.Forbidden: except discord.Forbidden:
if not interaction.response.is_done(): if interaction.response.is_done():
await interaction.followup.send("I don't have permission to create channels in that category.", ephemeral=True)
else:
await interaction.response.send_message("I don't have permission to create channels in that category.", ephemeral=True) await interaction.response.send_message("I don't have permission to create channels in that category.", ephemeral=True)
except Exception as e: except Exception as e:
if not interaction.response.is_done(): if interaction.response.is_done():
await interaction.followup.send(f"An unexpected error occurred: {e}", ephemeral=True)
else:
await interaction.response.send_message(f"An unexpected error occurred: {e}", ephemeral=True) await interaction.response.send_message(f"An unexpected error occurred: {e}", ephemeral=True)
# --- Button Views for Commands ---
class HireView(discord.ui.View): class HireView(discord.ui.View):
def __init__(self): def __init__(self, cog: "Hiring"):
super().__init__(timeout=None) super().__init__(timeout=None)
self.cog = cog
@discord.ui.button(label="Staff", style=discord.ButtonStyle.primary, custom_id="staff_apply_button_persistent") @discord.ui.button(label="Staff", style=discord.ButtonStyle.primary, custom_id="staff_apply_button")
async def staff_button(self, interaction: discord.Interaction, button: discord.ui.Button): async def staff_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await create_ticket(interaction, "staff", StaffApplicationModal) if not interaction.guild: return
category_id = await self.cog.config.guild(interaction.guild).staff_category()
await create_ticket(interaction, "staff", category_id, StaffApplicationModal())
@discord.ui.button(label="PM", style=discord.ButtonStyle.secondary, custom_id="pm_apply_button_persistent") @discord.ui.button(label="PM", style=discord.ButtonStyle.secondary, custom_id="pm_apply_button")
async def pm_button(self, interaction: discord.Interaction, button: discord.ui.Button): async def pm_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await create_ticket(interaction, "pm", PMApplicationModal) if not interaction.guild: return
category_id = await self.cog.config.guild(interaction.guild).pm_category()
await create_ticket(interaction, "pm", category_id, PMApplicationModal())
@discord.ui.button(label="HPM", style=discord.ButtonStyle.success, custom_id="hpm_apply_button_persistent") @discord.ui.button(label="HPM", style=discord.ButtonStyle.success, custom_id="hpm_apply_button")
async def hpm_button(self, interaction: discord.Interaction, button: discord.ui.Button): async def hpm_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await create_ticket(interaction, "hpm", HPMApplicationModal) if not interaction.guild: return
category_id = await self.cog.config.guild(interaction.guild).hpm_category()
await create_ticket(interaction, "hpm", category_id, HPMApplicationModal())
class WorkView(discord.ui.View): class WorkView(discord.ui.View):
def __init__(self): def __init__(self, cog: "Hiring"):
super().__init__(timeout=None) super().__init__(timeout=None)
self.cog = cog
@discord.ui.button(label="Apply for PM Position", style=discord.ButtonStyle.green, custom_id="work_apply_pm_persistent") @discord.ui.button(label="Apply for PM Position", style=discord.ButtonStyle.blurple, custom_id="work_apply_button")
async def work_apply_button(self, interaction: discord.Interaction, button: discord.ui.Button): async def work_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await create_ticket(interaction, "pm", PMApplicationModal) if not interaction.guild: return
category_id = await self.cog.config.guild(interaction.guild).pm_category()
await create_ticket(interaction, "pm", category_id, PMApplicationModal())
# --- Main Cog Class ---
class Hiring(commands.Cog): class Hiring(commands.Cog):
""" """
A cog for handling staff hiring applications. A cog for managing staff and PM applications.
""" """
def __init__(self, bot: "Red"):
def __init__(self, bot: Red):
self.bot = bot self.bot = bot
self.config = Config.get_conf(self, identifier=1234567891, force_registration=True) self.config = Config.get_conf(self, identifier=1122334455, force_registration=True)
default_guild = { default_guild = {
"staff_category": None,
"pm_category": None,
"hpm_category": None, "hpm_category": None,
"work_channel": None, "pm_category": None,
"closed_applications": {} "staff_category": None,
"work_channel": None # New setting for the /work command channel
} }
self.config.register_guild(**default_guild) self.config.register_guild(**default_guild)
# We need to make sure the views are persistent so buttons work after a restart
self.bot.add_view(HireView(self))
self.bot.add_view(WorkView(self))
async def cog_load(self):
self.bot.add_view(HireView())
self.bot.add_view(WorkView())
# --- Commands ---
@commands.hybrid_command() # type: ignore @commands.hybrid_command() # type: ignore
@app_commands.guild_only() @commands.guild_only()
@commands.admin_or_permissions(manage_guild=True) @commands.admin_or_permissions(manage_guild=True)
async def hire(self, ctx: commands.Context): async def hire(self, ctx: commands.Context):
"""Sends the hiring application view.""" """Post the application message with buttons in the current channel."""
view = HireView()
embed = discord.Embed( embed = discord.Embed(
title="Hiring Applications", title="Start an Application",
description="Please select the position you are applying for below.", description="Click a button below to open a ticket for the role you are interested in.",
color=await ctx.embed_color() color=0xadd8e6
) )
await ctx.send(embed=embed, view=view) await ctx.send(embed=embed, view=HireView(self))
@commands.hybrid_command() # type: ignore @commands.hybrid_command() # type: ignore
@app_commands.guild_only() @commands.guild_only()
@commands.admin_or_permissions(manage_guild=True) @commands.admin_or_permissions(manage_guild=True)
async def work(self, ctx: commands.Context): async def work(self, ctx: commands.Context):
"""Posts the persistent PM application button.""" """Post the PM hiring message in the configured work channel."""
guild = ctx.guild if not ctx.guild:
if not guild:
return return
work_channel_id = await self.config.guild(ctx.guild).work_channel()
work_channel_id = await self.config.guild(guild).work_channel()
if not work_channel_id: if not work_channel_id:
await ctx.send("The work channel has not been set. Please use `/hiringset workchannel`.") await ctx.send("The work channel has not been set. Please use `[p]hiringset workchannel` to set it.", ephemeral=True)
return return
channel = guild.get_channel(work_channel_id) work_channel = ctx.guild.get_channel(work_channel_id)
if not isinstance(channel, discord.TextChannel): if not isinstance(work_channel, discord.TextChannel):
await ctx.send("The configured work channel is invalid.") await ctx.send("The configured work channel is invalid or has been deleted.", ephemeral=True)
return return
view = WorkView()
embed = discord.Embed( embed = discord.Embed(
title="Partnership Manager Applications", title="Now Hiring: Partnership Managers",
description="Click the button below to apply for a Partnership Manager (PM) position.", description="We are for talented Partnership Managers (PMs) to join our team. If you are interested in applying, please click the button below to begin the application process.",
color=discord.Color.green() color=0xadd8e6
) )
try: try:
await channel.send(embed=embed, view=view) await work_channel.send(embed=embed, view=WorkView(self))
await ctx.send(f"Application message posted in {channel.mention}.", ephemeral=True) await ctx.send(f"Hiring message posted in {work_channel.mention}.", ephemeral=True)
except discord.Forbidden: except discord.Forbidden:
await ctx.send("I don't have permission to send messages in that channel.", ephemeral=True) await ctx.send(f"I don't have permission to send messages in {work_channel.mention}.", ephemeral=True)
# --- Settings Commands ---
@commands.group(aliases=["hset"]) # type: ignore @commands.group(aliases=["hset"]) # type: ignore
@commands.guild_only() @commands.guild_only()
@commands.admin_or_permissions(manage_guild=True) @commands.admin_or_permissions(manage_guild=True)
async def hiringset(self, ctx: commands.Context): async def hiringset(self, ctx: commands.Context):
"""Configure Hiring settings.""" """Configure the Hiring cog settings."""
pass pass
@hiringset.command(name="staffcategory") @hiringset.command(name="staffcategory")
async def set_staff_category(self, ctx: commands.Context, category: discord.CategoryChannel): async def set_staff_category(self, ctx: commands.Context, category: discord.CategoryChannel):
"""Set the category for Staff applications.""" """Set the category for Staff applications."""
if not ctx.guild: if not ctx.guild: return
return
await self.config.guild(ctx.guild).staff_category.set(category.id) await self.config.guild(ctx.guild).staff_category.set(category.id)
await ctx.send(f"Staff application category set to **{category.name}**.") await ctx.send(f"Staff application category set to **{category.name}**.")
@hiringset.command(name="pmcategory") @hiringset.command(name="pmcategory")
async def set_pm_category(self, ctx: commands.Context, category: discord.CategoryChannel): async def set_pm_category(self, ctx: commands.Context, category: discord.CategoryChannel):
"""Set the category for PM applications.""" """Set the category for PM applications."""
if not ctx.guild: if not ctx.guild: return
return
await self.config.guild(ctx.guild).pm_category.set(category.id) await self.config.guild(ctx.guild).pm_category.set(category.id)
await ctx.send(f"PM application category set to **{category.name}**.") await ctx.send(f"PM application category set to **{category.name}**.")
@hiringset.command(name="hpmcategory") @hiringset.command(name="hpmcategory")
async def set_hpm_category(self, ctx: commands.Context, category: discord.CategoryChannel): async def set_hpm_category(self, ctx: commands.Context, category: discord.CategoryChannel):
"""Set the category for HPM applications.""" """Set the category for HPM applications."""
if not ctx.guild: if not ctx.guild: return
return
await self.config.guild(ctx.guild).hpm_category.set(category.id) await self.config.guild(ctx.guild).hpm_category.set(category.id)
await ctx.send(f"HPM application category set to **{category.name}**.") await ctx.send(f"HPM application category set to **{category.name}**.")
@hiringset.command(name="workchannel") @hiringset.command(name="workchannel")
async def set_work_channel(self, ctx: commands.Context, channel: discord.TextChannel): async def set_work_channel(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel for the /work command announcements.""" """Set the channel where the /work hiring message will be posted."""
if not ctx.guild: if not ctx.guild: return
return
await self.config.guild(ctx.guild).work_channel.set(channel.id) await self.config.guild(ctx.guild).work_channel.set(channel.id)
await ctx.send(f"Work announcement channel set to {channel.mention}.") await ctx.send(f"The work channel has been set to {channel.mention}.")
async def setup(bot):
async def setup(bot: Red):
await bot.add_cog(Hiring(bot)) await bot.add_cog(Hiring(bot))

15
hiring/info.json Normal file
View File

@@ -0,0 +1,15 @@
{
"author": [ "unstableCogs" ],
"install_msg": "Thank you for installing the Hiring cog! Use `[p]help Hiring` for a list of commands.",
"name": "Hiring",
"short": "A ticket-based system for staff and PM applications.",
"description": "Provides /hire and /work commands to manage the staff and PM hiring process through a ticket system with forms and buttons.",
"tags": [
"hiring",
"tickets",
"utility",
"modmail"
],
"requirements": [],
"end_user_data_statement": "This cog stores user IDs and the content of their applications for the duration of the hiring process."
}

View File

@@ -1,6 +1,6 @@
{ {
"author" : ["KWUK"], "author" : ["KWUK"],
"install_msg" : "Thank you for installing my repo! If you need support, create an issue on Gitea or ping me in KWDS.", "install_msg" : "Thank you for installing my repo! If you need support, create an issue on Gitea or ping me in KWDS. Cogs: hiring, kbump, kofishop, modmail, mors, pp, rpg, iservice, welcomer. ",
"name" : "unstable-cogs", "name" : "unstable-cogs",
"short" : "Cogs for Red-DiscordBot!", "short" : "Cogs for Red-DiscordBot!",
"description" : "Cogs for Red-DiscordBot!", "description" : "Cogs for Red-DiscordBot!",

View File

1
kofishop/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .kofishop import setup

View File

@@ -1,85 +1,94 @@
import discord import discord
from redbot.core import commands, Config, app_commands from redbot.core import commands, Config
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from redbot.core.bot import Red from redbot.core.bot import Red
from typing import Optional
class OrderModal(discord.ui.Modal, title="Commission Order Form"): # --- Modals for the Commands ---
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)
class OrderModal(discord.ui.Modal, title="Commission/Shop Order"):
def __init__(self, cog: "KofiShop"): def __init__(self, cog: "KofiShop"):
super().__init__() super().__init__()
self.cog = cog self.cog = cog
comm_type = discord.ui.TextInput(label="What type of commission/item is this?")
payment_status = discord.ui.TextInput(label="Is this free or paid?")
description = discord.ui.TextInput(label="Please describe your request.", style=discord.TextStyle.paragraph)
questions = discord.ui.TextInput(label="Any questions for the artist?", style=discord.TextStyle.paragraph, required=False)
async def on_submit(self, interaction: discord.Interaction): async def on_submit(self, interaction: discord.Interaction):
guild = interaction.guild if not interaction.guild:
if not guild: return await interaction.response.send_message("This must be used in a server.", ephemeral=True)
return
channel_id = await self.cog.config.guild(guild).order_channel() order_channel_id = await self.cog.config.guild(interaction.guild).order_channel()
if not channel_id: if not order_channel_id:
await interaction.response.send_message("Order channel not set.", ephemeral=True) return await interaction.response.send_message("The order channel has not been set by an admin.", ephemeral=True)
return
channel = guild.get_channel(channel_id) order_channel = interaction.guild.get_channel(order_channel_id)
if not isinstance(channel, discord.TextChannel): if not isinstance(order_channel, discord.TextChannel):
await interaction.response.send_message("Invalid order channel.", ephemeral=True) return await interaction.response.send_message("The configured order channel is invalid.", ephemeral=True)
return
embed = discord.Embed(title=f"New Order from {interaction.user.name}", color=discord.Color.blurple()) embed = discord.Embed(
embed.add_field(name="Commission Type", value=self.commission_type.value, inline=False) title="New Order Placed",
description=f"Submitted by {interaction.user.mention}",
color=0x00ff00 # Green for new orders
)
embed.add_field(name="Item/Commission Type", value=self.comm_type.value, inline=False)
embed.add_field(name="Payment Status", value=self.payment_status.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) embed.add_field(name="Description", value=self.description.value, inline=False)
if self.questions.value: if self.questions.value:
embed.add_field(name="Questions", value=self.questions.value, inline=False) embed.add_field(name="Questions", value=self.questions.value, inline=False)
await channel.send(embed=embed) try:
await interaction.response.send_message("Your order has been submitted!", ephemeral=True) await order_channel.send(embed=embed)
await interaction.response.send_message("Your order has been successfully submitted!", ephemeral=True)
class ReviewModal(discord.ui.Modal, title="Shop Review"): except discord.Forbidden:
item_name = discord.ui.TextInput(label="What item/commission are you reviewing?") await interaction.response.send_message("I don't have permission to send messages in the order channel.", ephemeral=True)
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)
class ReviewModal(discord.ui.Modal, title="Leave a Review"):
def __init__(self, cog: "KofiShop"): def __init__(self, cog: "KofiShop"):
super().__init__() super().__init__()
self.cog = cog self.cog = cog
item_name = discord.ui.TextInput(label="What item/commission are you reviewing?")
rating = discord.ui.TextInput(label="Rating (e.g., 10/10)")
review_text = discord.ui.TextInput(label="Your Review", style=discord.TextStyle.paragraph)
async def on_submit(self, interaction: discord.Interaction): async def on_submit(self, interaction: discord.Interaction):
guild = interaction.guild if not interaction.guild:
if not guild: return await interaction.response.send_message("This must be used in a server.", ephemeral=True)
return
channel_id = await self.cog.config.guild(guild).review_channel() review_channel_id = await self.cog.config.guild(interaction.guild).review_channel()
if not channel_id: if not review_channel_id:
await interaction.response.send_message("Review channel not set.", ephemeral=True) return await interaction.response.send_message("The review channel has not been set by an admin.", ephemeral=True)
return
channel = guild.get_channel(channel_id) review_channel = interaction.guild.get_channel(review_channel_id)
if not isinstance(channel, discord.TextChannel): if not isinstance(review_channel, discord.TextChannel):
await interaction.response.send_message("Invalid review channel.", ephemeral=True) return await interaction.response.send_message("The configured review channel is invalid.", ephemeral=True)
return
embed = discord.Embed(title=f"New Review for {self.item_name.value}", color=discord.Color.gold()) embed = discord.Embed(
embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) title=f"New Review for: {self.item_name.value}",
embed.add_field(name="Rating", value=f"{self.rating.value}/10") description=f"Submitted by {interaction.user.mention}",
color=0xadd8e6 # Pastel Blue
)
embed.add_field(name="Rating", value=self.rating.value, inline=False)
embed.add_field(name="Review", value=self.review_text.value, inline=False) embed.add_field(name="Review", value=self.review_text.value, inline=False)
await channel.send(embed=embed) try:
await interaction.response.send_message("Thank you for your review!", ephemeral=True) await review_channel.send(embed=embed)
await interaction.response.send_message("Thank you! Your review has been submitted.", ephemeral=True)
except discord.Forbidden:
await interaction.response.send_message("I don't have permission to send messages in the review channel.", ephemeral=True)
class KofiShop(commands.Cog): class KofiShop(commands.Cog):
""" """
An interactive front-end for a Ko-fi store. A cog to manage Ko-fi shop orders and reviews.
""" """
def __init__(self, bot: "Red"):
def __init__(self, bot: Red):
self.bot = bot self.bot = bot
self.config = Config.get_conf(self, identifier=1234567894, force_registration=True) self.config = Config.get_conf(self, identifier=5566778899, force_registration=True)
default_guild = { default_guild = {
"order_channel": None, "order_channel": None,
"review_channel": None, "review_channel": None,
@@ -87,70 +96,80 @@ class KofiShop(commands.Cog):
} }
self.config.register_guild(**default_guild) self.config.register_guild(**default_guild)
@app_commands.command() # --- Commands ---
@app_commands.guild_only() @commands.hybrid_command()
async def order(self, interaction: discord.Interaction): @commands.guild_only()
"""Place an order for a commission.""" async def order(self, ctx: commands.Context):
await interaction.response.send_modal(OrderModal(self)) """Place an order for a shop or commission item."""
if not ctx.interaction:
@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 return
# We pass `self` (the cog instance) to the modal
await ctx.interaction.response.send_modal(OrderModal(self))
channel_id = await self.config.guild(guild).waitlist_channel() @commands.hybrid_command()
if not channel_id: @commands.guild_only()
await interaction.response.send_message("Waitlist channel not set.", ephemeral=True) async def review(self, ctx: commands.Context):
"""Leave a review for a completed shop or commission item."""
if not ctx.interaction:
return return
await ctx.interaction.response.send_modal(ReviewModal(self))
channel = guild.get_channel(channel_id) @commands.hybrid_command() # type: ignore
if not isinstance(channel, discord.TextChannel): @commands.guild_only()
await interaction.response.send_message("Invalid waitlist channel.", ephemeral=True) @commands.admin_or_permissions(manage_guild=True)
return async def waitlist(self, ctx: commands.Context, user: discord.Member, *, item: str):
"""Add a user and their requested item to the waitlist."""
if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel):
return await ctx.send("This command must be used in a server's text channel.", ephemeral=True)
embed = discord.Embed(description=f"{item} ིྀ {interaction.user.mention}{interaction.channel.mention if isinstance(interaction.channel, discord.TextChannel) else ''}") waitlist_channel_id = await self.config.guild(ctx.guild).waitlist_channel()
await channel.send(embed=embed) if not waitlist_channel_id:
await interaction.response.send_message("You have been added to the waitlist!", ephemeral=True) return await ctx.send("The waitlist channel has not been set by an admin.", ephemeral=True)
waitlist_channel = ctx.guild.get_channel(waitlist_channel_id)
if not isinstance(waitlist_channel, discord.TextChannel):
return await ctx.send("The configured waitlist channel is invalid.", ephemeral=True)
message = f"**{item}** ིྀ {user.mention} ✧ in {ctx.channel.mention}"
try:
await waitlist_channel.send(message)
await ctx.send(f"{user.mention} has been added to the waitlist for '{item}'.", ephemeral=True)
except discord.Forbidden:
await ctx.send(f"I don't have permission to send messages in the waitlist channel.", ephemeral=True)
# --- Settings Commands ---
@commands.group(aliases=["kset"]) # type: ignore @commands.group(aliases=["kset"]) # type: ignore
@commands.guild_only() @commands.guild_only()
@commands.admin_or_permissions(manage_guild=True) @commands.admin_or_permissions(manage_guild=True)
async def kofiset(self, ctx: commands.Context): async def kofiset(self, ctx: commands.Context):
"""Configure KofiShop settings.""" """
Configure the KofiShop settings.
"""
pass pass
@kofiset.command(name="orderchannel") @kofiset.command(name="orderchannel")
async def set_order_channel(self, ctx: commands.Context, channel: discord.TextChannel): async def kofiset_order(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel for new orders.""" """Set the channel where new orders will be sent."""
if not ctx.guild: if not ctx.guild: return
return
await self.config.guild(ctx.guild).order_channel.set(channel.id) await self.config.guild(ctx.guild).order_channel.set(channel.id)
await ctx.send(f"Order channel set to {channel.mention}") await ctx.send(f"Order channel has been set to {channel.mention}.")
@kofiset.command(name="reviewchannel") @kofiset.command(name="reviewchannel")
async def set_review_channel(self, ctx: commands.Context, channel: discord.TextChannel): async def kofiset_review(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel for new reviews.""" """Set the channel where new reviews will be sent."""
if not ctx.guild: if not ctx.guild: return
return
await self.config.guild(ctx.guild).review_channel.set(channel.id) await self.config.guild(ctx.guild).review_channel.set(channel.id)
await ctx.send(f"Review channel set to {channel.mention}") await ctx.send(f"Review channel has been set to {channel.mention}.")
@kofiset.command(name="waitlistchannel") @kofiset.command(name="waitlistchannel")
async def set_waitlist_channel(self, ctx: commands.Context, channel: discord.TextChannel): async def kofiset_waitlist(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel for waitlist notifications.""" """Set the channel where waitlist notifications will be sent."""
if not ctx.guild: if not ctx.guild: return
return
await self.config.guild(ctx.guild).waitlist_channel.set(channel.id) await self.config.guild(ctx.guild).waitlist_channel.set(channel.id)
await ctx.send(f"Waitlist channel set to {channel.mention}") await ctx.send(f"Waitlist channel has been set to {channel.mention}.")
async def setup(bot: "Red"):
async def setup(bot: Red):
await bot.add_cog(KofiShop(bot)) await bot.add_cog(KofiShop(bot))

View File

@@ -1,4 +0,0 @@
from .logging import Logging
async def setup(bot):
await bot.add_cog(Logging(bot))

View File

@@ -1,17 +0,0 @@
{
"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."
}

View File

@@ -1,276 +0,0 @@
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))

View File

@@ -0,0 +1 @@
from .modmail import setup

View File

@@ -1,90 +1,218 @@
import discord import discord
import datetime from redbot.core import commands, Config
from redbot.core import commands, Config, app_commands from redbot.core.bot import Red
from typing import Optional from typing import Optional
class Modmail(commands.Cog): class ModMail(commands.Cog):
""" """
A private, forum-based ModMail system. A configurable, forum-based ModMail system.
""" """
def __init__(self, bot): def __init__(self, bot: Red):
self.bot = bot self.bot = bot
self.config = Config.get_conf(self, identifier=1234567890, force_registration=True) # Initialize Red's Config system for storing settings per-server.
self.config = Config.get_conf(self, identifier=9876543210, force_registration=True)
# Define the default settings for each server.
default_guild = { default_guild = {
"forum_channel": None, "modmail_forum": None, # The ID of the forum channel for tickets
"enabled": False, "enabled": False, # Whether the system is on or off
"active_threads": {}, "active_threads": {} # A dictionary to track {user_id: thread_id}
"closed_threads": {} # NEW: To log closed tickets for purging
} }
# Register the default settings.
self.config.register_guild(**default_guild) self.config.register_guild(**default_guild)
# ... existing on_message listener ...
@commands.Cog.listener() @commands.Cog.listener()
async def on_message(self, message: discord.Message): async def on_message(self, message: discord.Message):
"""
This is the core event listener. It handles both incoming DMs from users
and outgoing replies from staff.
"""
# Ignore messages from bots to prevent loops.
if message.author.bot: if message.author.bot:
return return
# --- User to Staff DM Logic --- # --- Part 1: Handling DMs from Users ---
if isinstance(message.channel, discord.DMChannel): if isinstance(message.channel, discord.DMChannel):
# ... existing user DM logic ... await self.handle_dm(message)
pass
# --- Staff to User Reply Logic --- # --- Part 2: Handling Replies from Staff ---
elif isinstance(message.channel, discord.Thread): elif isinstance(message.channel, discord.Thread):
# ... existing staff reply logic ... await self.handle_staff_reply(message)
pass
@app_commands.command(name="close") async def handle_dm(self, message: discord.Message):
@app_commands.guild_only() """Handles messages sent directly to the bot."""
async def modmail_close(self, interaction: discord.Interaction, *, reason: Optional[str] = "No reason provided."): # Find a mutual server with the user.
"""Close the current ModMail ticket.""" guild = next((g for g in self.bot.guilds if g.get_member(message.author.id)), None)
guild = interaction.guild
if not guild: if not guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return return
# NEW: Safely check if the interaction has a channel settings = await self.config.guild(guild).all()
if not interaction.channel: if not settings["enabled"] or not settings["modmail_forum"]:
await interaction.response.send_message("This command cannot be used in this context.", ephemeral=True)
return return
# ... existing logic to check if it's a modmail thread ... forum_channel = guild.get_channel(settings["modmail_forum"])
if not isinstance(forum_channel, discord.ForumChannel):
return
thread_id_str = str(interaction.channel.id) active_threads = settings["active_threads"]
async with self.config.guild(guild).active_threads() as active_threads: user_id_str = str(message.author.id)
user_id = active_threads.pop(thread_id_str, None)
if user_id: # Check if the user already has an active thread.
# NEW: Log the closed thread with a timestamp if user_id_str in active_threads:
async with self.config.guild(guild).closed_threads() as closed_threads: thread_id = active_threads[user_id_str]
closed_threads[thread_id_str] = datetime.datetime.now(datetime.timezone.utc).isoformat() thread = guild.get_thread(thread_id)
if thread:
# ... existing logic to send final message and archive thread ... # Relay the message to the existing thread.
user = self.bot.get_user(int(user_id)) await thread.send(f"**{message.author.display_name}:** {message.content}")
if user: await message.add_reaction("")
embed = discord.Embed( return
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: else:
await interaction.response.send_message("This does not appear to be an active ModMail ticket.", ephemeral=True) # The thread was deleted, so we clean up our records.
async with self.config.guild(guild).active_threads() as threads:
del threads[user_id_str]
# Create a new thread for the user.
try:
thread_name = f"ModMail | {message.author.name}"
embed = discord.Embed(
title=f"New ModMail Thread",
description=f"**User:** {message.author.mention} (`{message.author.id}`)",
color=0xadd8e6 # Light grey pastel blue
)
embed.add_field(name="Initial Message", value=message.content, inline=False)
embed.set_footer(text="Staff can reply in this thread to send a message.")
thread_with_message = await forum_channel.create_thread(name=thread_name, embed=embed)
thread = thread_with_message.thread
async with self.config.guild(guild).active_threads() as threads:
threads[user_id_str] = thread.id
await message.channel.send("Your message has been received, and a ModMail ticket has been opened. Staff will be with you shortly.")
await message.add_reaction("")
except discord.Forbidden:
print(f"ModMail: I don't have permission to create threads in {forum_channel.name}.")
except Exception as e:
print(f"ModMail: An unexpected error occurred: {e}")
async def handle_staff_reply(self, message: discord.Message):
"""Handles messages sent by staff inside a ModMail thread."""
guild = message.guild
if not guild:
return
active_threads = await self.config.guild(guild).active_threads()
# Find which user this thread belongs to by checking our records.
thread_id_str = str(message.channel.id)
user_id = None
for uid, tid in active_threads.items():
if str(tid) == thread_id_str:
user_id = int(uid)
break
if not user_id:
# This is a regular thread, not a ModMail thread we're tracking.
return
user = guild.get_member(user_id)
if not user:
# User might have left the server.
await message.channel.send("⚠️ **Error:** Could not find the user. They may have left the server.")
return
# Send the staff's message to the user's DMs.
try:
embed = discord.Embed(
description=message.content,
color=0xadd8e6 # Light grey pastel blue
)
embed.set_author(name="Staff Response") # Anonymize the staff member
await user.send(embed=embed)
await message.add_reaction("📨") # Add a mail icon to show it was sent
except discord.Forbidden:
await message.channel.send("⚠️ **Error:** I could not send a DM to this user. They may have DMs disabled.")
except Exception as e:
await message.channel.send(f"⚠️ **Error:** An unexpected error occurred: {e}")
# --- Settings and Management Commands ---
@commands.group(aliases=["mmset"]) # type: ignore @commands.group(aliases=["mmset"]) # type: ignore
@commands.guild_only() @commands.guild_only()
@commands.admin_or_permissions(manage_guild=True) @commands.admin_or_permissions(manage_guild=True)
async def modmailset(self, ctx: commands.Context): async def modmailset(self, ctx: commands.Context):
"""Configure ModMail settings.""" """
Configure the ModMail settings for this server.
"""
pass pass
# ... existing modmailset subcommands ... @modmailset.command(name="forum")
async def modmailset_forum(self, ctx: commands.Context, channel: discord.ForumChannel):
"""Set the forum channel where ModMail tickets will be created."""
if not ctx.guild:
return
await self.config.guild(ctx.guild).modmail_forum.set(channel.id)
await ctx.send(f"The ModMail forum has been set to {channel.mention}.")
@modmailset.command(name="toggle")
async def modmailset_toggle(self, ctx: commands.Context):
"""Enable or disable the ModMail system on this server."""
if not ctx.guild:
return
current_status = await self.config.guild(ctx.guild).enabled()
new_status = not current_status
await self.config.guild(ctx.guild).enabled.set(new_status)
status_text = "enabled" if new_status else "disabled"
await ctx.send(f"The ModMail system has been {status_text}.")
@modmailset.command(name="close")
async def modmailset_close(self, ctx: commands.Context, *, reason: Optional[str] = "No reason provided."):
"""
Close the current ModMail thread.
You must run this command inside the thread you wish to close.
"""
if not ctx.guild or not isinstance(ctx.channel, discord.Thread):
await ctx.send("This command can only be run inside a ModMail thread.")
return
active_threads = await self.config.guild(ctx.guild).active_threads()
thread_id_str = str(ctx.channel.id)
user_id = None
for uid, tid in active_threads.items():
if str(tid) == thread_id_str:
user_id = int(uid)
break
if not user_id:
await ctx.send("This does not appear to be an active ModMail thread.")
return
# Clean up our records.
async with self.config.guild(ctx.guild).active_threads() as threads:
del threads[str(user_id)]
# Notify the user.
user = self.bot.get_user(user_id)
if user:
try:
embed = discord.Embed(
title="ModMail Ticket Closed",
description=f"Your ticket has been closed by staff.\n\n**Reason:** {reason}",
color=0xadd8e6
)
await user.send(embed=embed)
except discord.Forbidden:
pass # Can't notify user if DMs are closed
# Archive the thread.
await ctx.send(f"Ticket closed by {ctx.author.mention}. Archiving thread...")
await ctx.channel.edit(archived=True, locked=True)
# This required function allows Red to load the cog.
async def setup(bot: Red):
await bot.add_cog(ModMail(bot))

View File

@@ -1,4 +0,0 @@
from .mors import MORS
async def setup(bot):
await bot.add_cog(MORS(bot))

View File

@@ -1,11 +1,9 @@
{ {
"author": [ "author": [ "unstableCogs" ],
"unstableCogs" "install_msg": "Thank you for installing the Mass Outreach & Review System! For a full command list, please see the wiki.",
], "name": "MassOutreach",
"install_msg": "Thank you for installing the Mass Outreach & Review System cog!",
"name": "MORS",
"short": "A multi-step system for mass outreach and reviews.", "short": "A multi-step system for mass outreach and reviews.",
"description": "Manages a complete workflow for user outreach, including ad submission, time selection, and final 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.",
"tags": [ "tags": [
"mass", "mass",
"outreach", "outreach",
@@ -13,6 +11,6 @@
"tickets", "tickets",
"utility" "utility"
], ],
"requirements": [], "requirements": [ "rich" ],
"end_user_data_statement": "This cog stores user IDs, ticket information, and submitted reviews persistently for record-keeping purposes." "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."
} }

View File

@@ -1,444 +0,0 @@
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}")

View File

@@ -1,4 +0,0 @@
from .servicereview import ServiceReview
async def setup(bot):
await bot.add_cog(ServiceReview(bot))

View File

@@ -1,116 +0,0 @@
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))

View File

@@ -1,4 +0,0 @@
from .staffmsg import StaffMsg
async def setup(bot):
await bot.add_cog(StaffMsg(bot))

View File

@@ -1,17 +0,0 @@
{
"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."
}

View File

@@ -1,188 +0,0 @@
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))

4
translator/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .translator import Translator
async def setup(bot):
await bot.add_cog(Translator(bot))

View File

@@ -1,286 +0,0 @@
# -*- 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))

17
translator/info.json Normal file
View File

@@ -0,0 +1,17 @@
{
"author": [
"unstableCogs"
],
"install_msg": "Thank you for installing the Translator cog. Use the `/translate` command to get started.",
"name": "Translator",
"short": "Translates text into various fantasy and fun languages.",
"description": "A feature-rich translator cog ported from a web application. Supports numerous languages from Common to Valspiren, Sinary, and more. Includes a command to list all available languages.",
"tags": [
"translate",
"fun",
"roleplay",
"language"
],
"requirements": [],
"end_user_data_statement": "This cog does not store any end user data."
}

View File

@@ -1 +0,0 @@

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,9 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
# 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'
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,11 +0,0 @@
# -*- coding: utf-8 -*-
MAP = {'a':'ael','b':'\'eth','c':'cal','d':'dor','e':'elen','f':'fae','g':'\'gan','h':'h<EFBFBD>r','i':'ia','j':'yel','k':'\'ken','l':'lael','m':'mel','n':'n<EFBFBD>n','o':'oia','p':'\'pes','q':'qen','r':'rae','s':'sil','t':'t<EFBFBD>','u':'ui','v':'vae','w':'win','x':'\'xal','y':'yl','z':'z<EFBFBD>r'}
def get_language():
return {
'name': 'Elvish',
'type': 'greedy',
'map': MAP
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,9 +0,0 @@
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': ''
}

View File

@@ -1,9 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
# 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'
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,10 +0,0 @@
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': ''
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,8 +0,0 @@
# 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'
}

View File

@@ -1,10 +0,0 @@
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': ''
}

View File

@@ -1,8 +0,0 @@
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
}

View File

@@ -1,196 +0,0 @@
# -*- 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"<a?:\w+:\d+>")
# 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

View File

@@ -1,249 +1,162 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import discord import discord
import pkgutil import re
from redbot.core import commands, app_commands, Config from redbot.core import commands, app_commands
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()} # --- Translation Maps (Ported from JavaScript) ---
VALSPIREN_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'}
KITSUNE_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'}
ELVISH_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':'','u':'ui','v':'vae','w':'win','x':'\'xal','y':'yl','z':'zîr'}
DWARVISH_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'}
ORCISH_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'}
GOBLIN_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'}
DRACONIC_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'}
FELINE_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'}
AQUAN_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'}
CELESTIAL_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'}
INFERNAL_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'}
SYLVAN_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'}
GNOMISH_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'}
SINARY_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'}
SUCCUBUS_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'}
SCORPION_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'}
ABYSSAL_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'}
SPIRITUAL_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'}
OGRISH_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'}
AVIAN_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'}
DEVILISH_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'}
HERBILORE_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'}
FIENDISH_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\''}
ELEMENTAL_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'}
AQUATIC_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'}
CONSTRUCT_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'}
MYCONID_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'}
VOIDTOUCHED_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'}
LIZARDFOLK_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':'t\'char','u':'\'ux','v':'\'vax','w':'\'wax','x':'\'xax','y':'\'yax','z':'\'zax'}
ARACHNID_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'}
ANGELIC_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'}
RATFOLK_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'}
HALFTONGUE_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'}
UNDERCOMMON_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':'t\'lar','u':'uss','v':'v\'lar','w':'wyl','x':'x\'lar','y':'y\'lar','z':'z\'ress'}
MORSE_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':'-----',' ':'/'}
LEET_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'}
class Translator(CommandsMixin, TranslationLogicMixin, commands.Cog): # --- Translation Logic Functions ---
def generic_translator(text, translation_map, separator):
"""Translate text by mapping characters and joining them."""
translated_words = []
for word in text.split(' '):
translated_chars = [translation_map.get(char.lower(), char) for char in word]
translated_words.append(separator.join(translated_chars))
return (' ' if separator else ' ').join(translated_words)
# --- Main Cog Class ---
class Translator(commands.Cog):
"""A cog for translating text into various languages.""" """A cog for translating text into various languages."""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.config = Config.get_conf(self, identifier=8675309, force_registration=True) self.languages = self._setup_languages()
self.config.register_global(languages={})
self.config.register_user(sonas={}, proxy_enabled=False)
self.config.register_channel(autotranslate_users={})
self.all_languages = {} def _setup_languages(self):
self.translate_to_common_context_menu = app_commands.ContextMenu( """Initializes the language definitions."""
name="Translate to Common", # Using lambdas to keep it concise, matching the JS structure
callback=self.translate_to_common_context, return {
'common': {'name': 'Common', 'translate': lambda text: text},
'abyssal': {'name': 'Abyssal', 'translate': lambda text: generic_translator(text, ABYSSAL_MAP, ' ')},
'angelic': {'name': 'Angelic', 'translate': lambda text: generic_translator(text, ANGELIC_MAP, ' ')},
'aquan': {'name': 'Aquan', 'translate': lambda text: generic_translator(text, AQUAN_MAP, ' ')},
'aquatic': {'name': 'Aquatic', 'translate': lambda text: generic_translator(text, AQUATIC_MAP, ' ')},
'arachnid': {'name': 'Arachnid', 'translate': lambda text: generic_translator(text, ARACHNID_MAP, ' ')},
'avian': {'name': 'Avian', 'translate': lambda text: generic_translator(text, AVIAN_MAP, ' ')},
'celestial': {'name': 'Celestial', 'translate': lambda text: generic_translator(text, CELESTIAL_MAP, ' ')},
'construct': {'name': 'Construct', 'translate': lambda text: generic_translator(text, CONSTRUCT_MAP, ' ')},
'devilish': {'name': 'Devilish', 'translate': lambda text: generic_translator(text, DEVILISH_MAP, ' ')},
'draconic': {'name': 'Draconic', 'translate': lambda text: generic_translator(text, DRACONIC_MAP, ' ')},
'dwarvish': {'name': 'Dwarvish', 'translate': lambda text: generic_translator(text, DWARVISH_MAP, ' ')},
'elemental': {'name': 'Elemental', 'translate': lambda text: generic_translator(text, ELEMENTAL_MAP, ' ')},
'elvish': {'name': 'Elvish', 'translate': lambda text: generic_translator(text, ELVISH_MAP, ' ')},
'feline': {'name': 'Feline', 'translate': lambda text: generic_translator(text, FELINE_MAP, ' ')},
'fiendish': {'name': 'Fiendish', 'translate': lambda text: generic_translator(text, FIENDISH_MAP, ' ')},
'gnomish': {'name': 'Gnomish', 'translate': lambda text: generic_translator(text, GNOMISH_MAP, ' ')},
'goblin': {'name': 'Goblin', 'translate': lambda text: generic_translator(text, GOBLIN_MAP, ' ')},
'halftongue': {'name': 'Half-Tongue', 'translate': lambda text: generic_translator(text, HALFTONGUE_MAP, ' ')},
'herbilore': {'name': 'Herbilore', 'translate': lambda text: generic_translator(text, HERBILORE_MAP, ' ')},
'infernal': {'name': 'Infernal', 'translate': lambda text: generic_translator(text, INFERNAL_MAP, ' ')},
'kitsune': {'name': 'Kitsune', 'translate': lambda text: generic_translator(text, KITSUNE_MAP, '')},
'leet': {'name': 'Leet Speak', 'translate': lambda text: generic_translator(text, LEET_MAP, '')},
'lizardfolk': {'name': 'Lizardfolk', 'translate': lambda text: generic_translator(text, LIZARDFOLK_MAP, ' ')},
'morse': {'name': 'Morse Code', 'translate': lambda text: generic_translator(text, MORSE_MAP, ' ')},
'myconid': {'name': 'Myconid', 'translate': lambda text: generic_translator(text, MYCONID_MAP, ' ')},
'ogrish': {'name': 'Ogrish', 'translate': lambda text: generic_translator(text, OGRISH_MAP, ' ')},
'orcish': {'name': 'Orcish', 'translate': lambda text: generic_translator(text, ORCISH_MAP, ' ')},
'ratfolk': {'name': 'Ratfolk', 'translate': lambda text: generic_translator(text, RATFOLK_MAP, ' ')},
'scorpion': {'name': 'Scorpion', 'translate': lambda text: generic_translator(text, SCORPION_MAP, ' ')},
'sinary': {'name': 'Sinary', 'translate': lambda text: generic_translator(text, SINARY_MAP, '')},
'spiritual': {'name': 'Spiritual', 'translate': lambda text: generic_translator(text, SPIRITUAL_MAP, ' ')},
'succubus': {'name': 'Succubus', 'translate': lambda text: generic_translator(text, SUCCUBUS_MAP, ' ')},
'sylvan': {'name': 'Sylvan', 'translate': lambda text: generic_translator(text, SYLVAN_MAP, ' ')},
'undercommon': {'name': 'Undercommon', 'translate': lambda text: generic_translator(text, UNDERCOMMON_MAP, ' ')},
'valspiren': {'name': 'Valspiren', 'translate': lambda text: generic_translator(text, VALSPIREN_MAP, '')},
'voidtouched': {'name': 'Voidtouched', 'translate': lambda text: generic_translator(text, VOIDTOUCHED_MAP, ' ')},
}
async def language_autocomplete(self, interaction: discord.Interaction, current: str) -> list[app_commands.Choice[str]]:
"""Autocomplete for language choices."""
choices = [
app_commands.Choice(name=lang_data['name'], value=lang_key)
for lang_key, lang_data in self.languages.items()
if current.lower() in lang_data['name'].lower()
]
return choices[:25]
@commands.hybrid_command(name="translate", description="Translate text to a selected language.")
@app_commands.describe(
language="The language to translate the text into.",
text="The text to translate."
) )
@app_commands.autocomplete(language=language_autocomplete)
async def translate(self, ctx: commands.Context, language: str, *, text: str):
"""
Translates the given text into the specified language.
async def cog_load(self): To see a list of available languages, use the command without specifying a language.
"""Load all languages from config and build the runtime objects.""" """
self.bot.tree.add_command(self.translate_to_common_context_menu) language_key = language.lower()
await self._initialize_languages()
async def cog_unload(self): if language_key not in self.languages:
"""Remove context menu on cog unload.""" await ctx.send(f"Sorry, '{language}' is not a valid language. Please choose from the list.", ephemeral=True)
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 return
prefix = (await self.bot.get_prefix(message))[0] translator_func = self.languages[language_key]['translate']
if message.content.startswith(prefix): translated_text = translator_func(text)
return
if message.reference and message.content.lower() == 'translate': embed = discord.Embed(
try: title=f"Translation to {self.languages[language_key]['name']}",
replied_message = await message.channel.fetch_message(message.reference.message_id) color=discord.Color.blue()
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 embed.add_field(name="Original Text", value=f"```\n{text}\n```", inline=False)
embed.add_field(name="Translated Text", value=f"```\n{translated_text}\n```", inline=False)
if message.channel.permissions_for(message.guild.me).manage_messages: await ctx.send(embed=embed)
await message.delete()
return
except Exception:
return
user_settings = await self.config.user(message.author).all() @commands.hybrid_command(name="languages", description="List all available languages for translation.")
if not user_settings['sonas']: async def list_languages(self, ctx: commands.Context):
return """Provides a list of all available languages."""
content = message.content sorted_languages = sorted(self.languages.values(), key=lambda x: x['name'])
matched_sona = None
for sona_data in user_settings['sonas'].values(): description = "\n".join([lang['name'] for lang in sorted_languages])
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 embed = discord.Embed(
if end_claw and end_claw != "": title="Available Languages",
if not content.endswith(end_claw) or len(content) < len(start_claw) + len(end_claw): description=description,
continue color=discord.Color.green()
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: await ctx.send(embed=embed)
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: async def setup(bot):
start_claw, end_claw = matched_sona['claws'] await bot.add_cog(Translator(bot))
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
)

View File

@@ -1,28 +0,0 @@
# -*- 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()

View File

@@ -1,121 +1,195 @@
# welcomer.py
import discord import discord
from redbot.core import commands, Config from redbot.core import commands, Config
from redbot.core.bot import Red from redbot.core.bot import Red
from typing import Literal
class Welcomer(commands.Cog): class Welcomer(commands.Cog):
""" """
A configurable cog to automatically welcome new users with multiple modes. A configurable cog to welcome new users to a server.
""" """
def __init__(self, bot: Red): def __init__(self, bot: Red):
self.bot = bot self.bot = bot
# Initialize Red's Config system. This will store our settings.
# The "GUILD" identifier means settings will be saved on a per-server basis.
self.config = Config.get_conf(self, identifier=1234567890, force_registration=True) self.config = Config.get_conf(self, identifier=1234567890, force_registration=True)
# Define the default settings for each server.
default_guild = { default_guild = {
"welcome_channel": None, "welcome_channel": None, # The ID of the channel to send welcomes to
"welcome_message": "Welcome to the server, {user.mention}!", "welcome_message": "Welcome to the server, {user.mention}!", # Default message
"enabled": False, "enabled": False # Whether the system is on or off
"welcome_mode": "normal" # 'normal' or 'solstira'
} }
# Register the default settings.
self.config.register_guild(**default_guild) self.config.register_guild(**default_guild)
@commands.Cog.listener() @commands.Cog.listener()
async def on_member_join(self, member: discord.Member): async def on_member_join(self, member: discord.Member):
"""
The event listener that runs when a new member joins.
"""
guild = member.guild guild = member.guild
# Check if the welcomer is enabled for this server.
if not await self.config.guild(guild).enabled(): if not await self.config.guild(guild).enabled():
return return
mode = await self.config.guild(guild).welcome_mode() # Get the welcome channel ID from our saved settings.
if mode == "solstira":
# Hardcoded Solstira welcome event
channel_id = 1409965732669948024
else: # Normal mode
channel_id = await self.config.guild(guild).welcome_channel() channel_id = await self.config.guild(guild).welcome_channel()
if not channel_id: if not channel_id:
return return # If no channel is set, do nothing.
# Try to find the channel object in the server.
channel = guild.get_channel(channel_id) channel = guild.get_channel(channel_id)
if not channel:
# The channel might have been deleted.
return
# Explicitly check if the channel is a TextChannel before trying to send a message.
if not isinstance(channel, discord.TextChannel): if not isinstance(channel, discord.TextChannel):
print(f"Welcomer: Configured channel '{channel.name}' in {guild.name} is not a text channel.")
return return
if not channel.permissions_for(guild.me).send_messages: # Get the custom welcome message from our settings.
return
if mode == "solstira":
content = (
f"{member.mention}\n"
f"- `guide` <https://discord.com/channels/1409939326382651/140993936542124073>\n"
f"- <a:f_purpleflower:1409967049561524859> `roles` <@&1409937150>\n"
f"(<https://discord.com/channels/1409939326382651/140996735493797150>)\n"
f"- `psa` (<https://discord.com/channels/1409939326382651/140996779492440732>)"
)
embed = discord.Embed(description="--", color=0x8b9ed7)
try:
await channel.send(content, embed=embed)
except discord.HTTPException:
pass
else: # Normal mode
message_template = await self.config.guild(guild).welcome_message() message_template = await self.config.guild(guild).welcome_message()
# Format the message with the new member's info.
# .format() is a safe way to replace placeholders.
formatted_message = message_template.format( formatted_message = message_template.format(
user=member, user=member,
user_mention=member.mention, user_mention=member.mention,
user_name=member.name, user_name=member.name,
server_name=guild.name server_name=guild.name
) )
# Check if we have permission to send messages in the channel.
if not channel.permissions_for(guild.me).send_messages:
# We can't send a message, so we'll just log this internally.
print(f"Welcomer: No permission to send messages in {channel.name} in {guild.name}.")
return
try: try:
await channel.send(formatted_message) await channel.send(formatted_message)
except discord.HTTPException: except discord.Forbidden:
# This is a final safety check in case permissions change suddenly.
pass pass
except discord.HTTPException as e:
# This can happen if the message is too long or there's a Discord API error.
print(f"Welcomer: Failed to send welcome message in {guild.name}: {e}")
# Create a command group for all our settings commands.
@commands.group(aliases=["wset"]) # type: ignore @commands.group(aliases=["wset"]) # type: ignore
@commands.guild_only() @commands.guild_only() # Ensures this command and its subcommands can only be run in a server
@commands.admin_or_permissions(manage_guild=True) @commands.admin_or_permissions(manage_guild=True)
async def welcomeset(self, ctx: commands.Context): async def welcomeset(self, ctx: commands.Context):
"""Commands for configuring the welcome system.""" """
Configure the welcomer settings for this server.
"""
pass pass
@welcomeset.command(name="mode") @welcomeset.command(name="channel", aliases=["chnl"])
async def set_welcome_mode(self, ctx: commands.Context, mode: Literal["normal", "solstira"]): async def welcomeset_channel(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the welcome mode for this server. """
Set the channel where welcome messages will be sent.
Modes: Example:
- `normal`: A simple, configurable welcome message. [p]welcomeset channel #welcome
- `solstira`: The special, complex welcome event. """
if not ctx.guild:
return # This check satisfies the type checker
await self.config.guild(ctx.guild).welcome_channel.set(channel.id)
await ctx.send(f"The welcome channel has been set to {channel.mention}.")
@welcomeset.command(name="message", aliases=["msg"])
async def welcomeset_message(self, ctx: commands.Context, *, message: str):
"""
Set the custom welcome message.
You can use these placeholders:
{user} - The user object.
{user_mention} - Pings the user.
{user_name} - The user's name.
{server_name} - The name of this server.
Example:
[p]welcomeset message Hello {user_mention}, welcome to {server_name}!
""" """
if not ctx.guild:
return
await self.config.guild(ctx.guild).welcome_mode.set(mode.lower())
await ctx.send(f"Welcome mode has been set to **{mode.lower()}**.")
@welcomeset.command(name="channel")
async def set_welcome_channel(self, ctx: commands.Context, channel: discord.TextChannel):
"""(Normal Mode) Set the channel for welcome messages."""
if not ctx.guild:
return
await self.config.guild(ctx.guild).welcome_channel.set(channel.id)
await ctx.send(f"Welcome channel set to {channel.mention}")
@welcomeset.command(name="message")
async def set_welcome_message(self, ctx: commands.Context, *, message: str):
"""(Normal Mode) Set the welcome message. Use placeholders like {user.mention}."""
if not ctx.guild: if not ctx.guild:
return return
await self.config.guild(ctx.guild).welcome_message.set(message) await self.config.guild(ctx.guild).welcome_message.set(message)
await ctx.send("Welcome message updated.") await ctx.send(f"The welcome message has been updated.")
# Send a preview of the new message.
preview = message.format(
user=ctx.author,
user_mention=ctx.author.mention,
user_name=ctx.author.name,
server_name=ctx.guild.name
)
await ctx.send(f"**Preview:**\n{preview}")
@welcomeset.command(name="toggle") @welcomeset.command(name="toggle", aliases=["on", "off"])
async def toggle_welcome(self, ctx: commands.Context): async def welcomeset_toggle(self, ctx: commands.Context):
"""Toggle the welcome system on or off.""" """
Enable or disable the welcomer system on this server.
"""
if not ctx.guild: if not ctx.guild:
return return
current_status = await self.config.guild(ctx.guild).enabled() current_status = await self.config.guild(ctx.guild).enabled()
new_status = not current_status new_status = not current_status
await self.config.guild(ctx.guild).enabled.set(new_status) await self.config.guild(ctx.guild).enabled.set(new_status)
status_text = "enabled" if new_status else "disabled" status_text = "enabled" if new_status else "disabled"
await ctx.send(f"Welcome system has been {status_text}.") await ctx.send(f"The welcomer system has been {status_text}.")
@welcomeset.command(name="settings", aliases=["show", "status"])
async def welcomeset_settings(self, ctx: commands.Context):
"""
Show the current welcomer settings for this server.
"""
if not ctx.guild:
return
settings = await self.config.guild(ctx.guild).all()
channel_id = settings['welcome_channel']
channel = ctx.guild.get_channel(channel_id) if channel_id else None
message = settings['welcome_message']
enabled = "Enabled" if settings['enabled'] else "Disabled"
embed = discord.Embed(title="Welcomer Settings", color=await ctx.embed_color())
embed.add_field(name="Status", value=enabled, inline=False)
embed.add_field(name="Channel", value=channel.mention if isinstance(channel, discord.TextChannel) else "Not Set", inline=False)
embed.add_field(name="Message", value=f"```{message}```", inline=False)
await ctx.send(embed=embed)
@welcomeset.command(name="test")
async def welcomeset_test(self, ctx: commands.Context):
"""
Test the welcome message by sending a preview to this channel.
"""
if not ctx.guild:
return
message_template = await self.config.guild(ctx.guild).welcome_message()
preview = message_template.format(
user=ctx.author,
user_mention=ctx.author.mention,
user_name=ctx.author.name,
server_name=ctx.guild.name
)
await ctx.send(f"**Welcome Message Preview**:\n{preview}")
@welcomeset.command(name="reset")
async def welcomeset_reset(self, ctx: commands.Context):
"""
Reset all welcomer settings to their defaults.
"""
if not ctx.guild:
return
await self.config.guild(ctx.guild).clear()
await ctx.send("The welcomer settings have been reset to their defaults.")
# This function allows Red to load the cog.
# It is required in every cog file.
async def setup(bot: Red): async def setup(bot: Red):
await bot.add_cog(Welcomer(bot)) await bot.add_cog(Welcomer(bot))