Introduces multilingual translation cog supporting fantasy languages Adds proxying features for role-playing with sonas and auto-translation Includes admin commands for managing custom languages and context menus Replaces basic translation with enhanced functionality for Discord bot
250 lines
11 KiB
Python
250 lines
11 KiB
Python
# -*- 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
|
|
)
|
|
|