Files
unstable-cogs/translator/translator.py
Unstable Kitsune 0e5afcb999 feat: add advanced translator cog with proxy and languages
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
2025-09-22 23:23:57 -04:00

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
)