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