From 3627d32e540e0b4ab8b673cd011cb8f36276c73d Mon Sep 17 00:00:00 2001 From: Kaveen Kumarasinghe Date: Mon, 16 Jan 2023 03:25:32 -0500 Subject: [PATCH] Translations with DeepL --- README.md | 43 ++++++++---- cogs/commands.py | 39 ++++++++++- cogs/translation_service_cog.py | 115 ++++++++++++++++++++++++++++++++ gpt3discord.py | 11 ++- models/autocomplete_model.py | 13 ++++ models/check_model.py | 18 +++++ models/deepl_model.py | 98 +++++++++++++++++++++++++++ services/environment_service.py | 34 ++++++++++ 8 files changed, 354 insertions(+), 17 deletions(-) create mode 100644 cogs/translation_service_cog.py create mode 100644 models/deepl_model.py diff --git a/README.md b/README.md index 2d0582e..5bf698b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ # Overview A robust, all-in-one GPT3 interface for Discord. Chat just like ChatGPT right inside Discord! Generate beautiful AI art using DALL-E 2! Automatically moderate your server using AI! A thorough integration with permanent conversation memory, automatic request retry, fault tolerance and reliability for servers of any scale, and much more. -SUPPORT SERVER FOR BOT SETUP: https://discord.gg/WvAHXDMS7Q (You can NOT use the bot here, it is for setup support ONLY) +SUPPORT SERVER FOR BOT SETUP: https://discord.gg/WvAHXDMS7Q (You can try out the bot here also in a limited fashion) # Screenshots

@@ -23,14 +23,12 @@ SUPPORT SERVER FOR BOT SETUP: https://discord.gg/WvAHXDMS7Q (You can NOT use the

# Recent Notable Updates -- **Edit Requests** - Ask GPT to edit a piece of text with a given instruction using a specific OpenAI edits model! `/gpt edit`! +- **Translations with DeepL** - DeepL integration for translations. `/translate` - **Context menu commands** - Allow people to prompt GPT and DALL-E directly by right clicking a message: - - - -- **Automatic retry on API errors** - The bot will automatically retry API requests if they fail due to some issue with OpenAI's APIs, this is becoming increasingly important now as their APIs become under heavy load. +
+
- **AI-BASED SERVER MODERATION** - GPT3Discord now has a built-in AI-based moderation system that can automatically detect and remove toxic messages from your server. This is a great way to keep your server safe and clean, and it's completely automatic and **free**! Check out the commands section to learn how to enable it! @@ -51,6 +49,8 @@ SUPPORT SERVER FOR BOT SETUP: https://discord.gg/WvAHXDMS7Q (You can NOT use the - **Edit Requests** - Ask GPT to edit a piece of text or code with a given instruction. `/gpt edit ` +- **DeepL Translations** - Translate text with DeepL. `/translate ` + - **Redo Requests** - A simple button after the GPT3 response or DALL-E generation allows you to redo the initial prompt you asked. You can also redo conversation messages by just editing your message! - **Automatic AI-Based Server Moderation** - Moderate your server automatically with AI! @@ -145,7 +145,7 @@ If you'd like to help us test and fine tune our thresholds for the moderation se **The above server is NOT for support or discussions about GPT3Discord** -# Permanent Memory +# Permanent Memory and Conversations Permanent memory has now been implemented into the bot, using the OpenAI Ada embeddings endpoint, and Pinecone DB. PineconeDB is a vector database. The OpenAI Ada embeddings endpoint turns pieces of text into embeddings. The way that this feature works is by embedding the user prompts and the GPT3 responses, storing them in a pinecone index, and then retrieving the most relevant bits of conversation whenever a new user prompt is given in a conversation. @@ -161,17 +161,15 @@ To get a pinecone token, you can sign up for a free pinecone account here: https After signing up for a free pinecone account, you need to create an index in pinecone. To do this, go to the pinecone dashboard and click "Create Index" on the top right. - +
Then, name the index `conversation-embeddings`, set the dimensions to `1536`, and set the metric to `DotProduct`: - - -Moreover, an important thing to keep in mind is: pinecone indexes are currently not automatically cleared by the bot, so you will eventually need to clear the index manually through the pinecone website if things are getting too slow (although it should be a very long time until this happens). Pinecone indexes are keyed on the `metadata` field using the thread id of the conversation thread. +
Permanent memory using pinecone is still in alpha, I will be working on cleaning up this work, adding auto-clearing, and optimizing for stability and reliability, any help and feedback is appreciated (**add me on Discord Kaveen#0001 for pinecone help**)! If at any time you're having too many issues with pinecone, simply remove the `PINECONE_TOKEN` line in your `.env` file and the bot will revert to using conversation summarizations. -# Permanent overrides in threads +### Permanent overrides in threads This bot now supports having overrides be permanent in an entire conversation if you use an opener file which includes them. The new opener files should be .json files formatted like this. `text` corresponds to what you want the conversational opener to be and the rest map 1:1 to the appropriate model settings. An example .json file is included by the name of `english_translator.json` in the `openers` folder ```json { @@ -183,6 +181,24 @@ This bot now supports having overrides be permanent in an entire conversation if } ``` +# Translations with DeepL +This bot supports and uses DeepL for translations (optionally). If you want to enable the translations service, you can add a line in your `.env` file as follows: + +``` +DEEPL_TOKEN="your deepl token" +``` + +You can get a DeepL token by signing up at https://www.deepl.com/pro-api?cta=header-pro-api/ and clicking on the *free plan* to start. The DeepL translation service unlocks some new commands for your bot: + +`/translate ` - Translate any given piece of text into the language that you provide + +`/languages` - See a list of all supported languages + +Using DeepL also adds a new app menu button (when you right click a message) to the bot which allows you to quickly translate any message in a channel into any language you want: + + + + # User-Input API Keys (Multi-key tenancy) This bot supports multi-user tenancy in regards to API keys. This means that, if you wanted, you could make it such that each user needs to enter their own API key in order to use commands that use GPT3 and DALLE. @@ -196,7 +212,8 @@ Then, restart the bot, and it will set up the system for everyone to input their The bot will use SQLite to store API keys for the users, each user's key will be saved with a USER_ID <> API_KEY mapping in SQLite, and will be persistent across restarts. All the data will be saved in a file called `user_key_db.sqlite` in the current working directory of the bot. With this feature enabled, any attempt to use a GPT3 or DALL-E command without a valid API key set for the user will pop up the following modal for them to enter their API key: - + +
Once the user enters their key, the bot will send a small test request to OpenAI to validate that the key indeed works, if not, it will tell the user to try again and tell them why it did not work. diff --git a/cogs/commands.py b/cogs/commands.py index 3099b73..2cdba30 100644 --- a/cogs/commands.py +++ b/cogs/commands.py @@ -3,11 +3,10 @@ from pycord.multicog import add_to_group from services.environment_service import EnvService from models.check_model import Check -from models.autocomplete_model import Settings_autocompleter, File_autocompleter +from models.autocomplete_model import Settings_autocompleter, File_autocompleter, Translations_autocompleter ALLOWED_GUILDS = EnvService.get_allowed_guilds() - class Commands(discord.Cog, name="Commands"): """Cog containing all slash and context commands as one-liners""" @@ -22,6 +21,7 @@ class Commands(discord.Cog, name="Commands"): image_draw_cog, image_service_cog, moderations_cog, + translations_cog=None, ): super().__init__() self.bot = bot @@ -33,6 +33,7 @@ class Commands(discord.Cog, name="Commands"): self.image_draw_cog = image_draw_cog self.image_service_cog = image_service_cog self.moderations_cog = moderations_cog + self.translations_cog = translations_cog # Create slash command groups dalle = discord.SlashCommandGroup( @@ -502,3 +503,37 @@ class Commands(discord.Cog, name="Commands"): ) async def draw_action(self, ctx, message: discord.Message): await self.image_draw_cog.draw_action(ctx, message) + + """ + Translation commands and actions + """ + @discord.slash_command(name="translate", description="Translate text to a given language", guild_ids=ALLOWED_GUILDS, + checks=[Check.check_translator_roles()], +) + @discord.option(name="text", description="The text to translate", required=True) + @discord.option(name="target_language", description="The language to translate to", required=True, autocomplete=Translations_autocompleter.get_languages) + @discord.guild_only() + async def translate(self, ctx: discord.ApplicationContext, text: str, target_language: str): + if self.translations_cog: + await self.translations_cog.translate_command(ctx, text, target_language) + else: + await ctx.respond("Translations are disabled on this server.", ephemeral=True) + + @discord.slash_command(name="languages", description="View the supported languages for translation", guild_ids=ALLOWED_GUILDS, + checks=[Check.check_translator_roles()],) + @discord.guild_only() + async def languages(self, ctx: discord.ApplicationContext): + if self.translations_cog: + await self.translations_cog.languages_command(ctx) + else: + await ctx.respond("Translations are disabled on this server.", ephemeral=True) + + + @discord.message_command( + name="Translate", guild_ids=ALLOWED_GUILDS, checks=[Check.check_translator_roles()] + ) + async def translate_action(self, ctx, message: discord.Message): + if self.translations_cog: + await self.translations_cog.translate_action(ctx, message) + else: + await ctx.respond("Translations are disabled on this server.", ephemeral=True) \ No newline at end of file diff --git a/cogs/translation_service_cog.py b/cogs/translation_service_cog.py new file mode 100644 index 0000000..c2542ab --- /dev/null +++ b/cogs/translation_service_cog.py @@ -0,0 +1,115 @@ +import asyncio +import os +import traceback + +import aiohttp +import discord + +# We don't use the converser cog here because we want to be able to redo for the last images and text prompts at the same time +from sqlitedict import SqliteDict + +from models.deepl_model import TranslationModel +from services.environment_service import EnvService +from services.image_service import ImageService +from services.text_service import TextService +ALLOWED_GUILDS = EnvService.get_allowed_guilds() + + +def build_translation_embed( text, target_language): + """Build an embed for the translation""" + embed = discord.Embed( + title=f"Translation results", + color=0x311432, + ) + embed.add_field(name="Original text", value=text, inline=False) + embed.add_field(name="Target language", value=target_language, inline=False) + + return embed +class TranslationService(discord.Cog, name="TranslationService"): + """Cog containing a draw commands and file management for saved images""" + def __init__( + self, bot, translation_model, + ): + super().__init__() + self.bot = bot + self.translation_model = translation_model + # Make a mapping of all the country codes and their full country names: + + def build_supported_language_embed(self): + """Build an embed for the translation""" + embed = discord.Embed( + title=f"Translator supported languages", + color=0x311432, + ) + # Add the list of supported languages in a nice format + embed.add_field( + name="Languages", + value=", ".join( + [ + f"{name}" + for name in TranslationModel.get_all_country_names() + ] + ), + inline=False, + ) + + return embed + + async def translate_command(self, ctx, text, target_language): + """Delete all local images""" + await ctx.defer() + # TODO Add pagination! + + if target_language.lower().strip() not in TranslationModel.get_all_country_names(lower=True): + await ctx.respond( + f"The language {target_language} is not recognized or supported. Please use `/languages` to see the list of supported languages." + ) + return + + try: + response = await self.translation_model.send_translate_request(text, TranslationModel.get_country_code_from_name(target_language)) + except aiohttp.ClientResponseError as e: + await ctx.respond(f"There was an error with the DeepL API: {e.message}") + return + + await ctx.respond(embed=build_translation_embed(text, response)) + + async def translate_action(self, ctx, message): + await ctx.defer(ephemeral=True) + selection_message = await ctx.respond("Select language", ephemeral=True, delete_after=60) + await selection_message.edit(view=TranslateView(self.translation_model, message, selection_message)) + + async def languages_command(self, ctx): + """Show all languages supported for translation""" + await ctx.defer() + await ctx.respond(embed=self.build_supported_language_embed()) + +class TranslateView(discord.ui.View): + + def __init__(self, translation_model, message, selection_message): + super().__init__() + self.translation_model = translation_model + self.message = message + self.selection_message = selection_message + + @discord.ui.select( # the decorator that lets you specify the properties of the select menu + placeholder = "Language", # the placeholder text that will be displayed if nothing is selected + min_values = 1, # the minimum number of values that must be selected by the users + max_values = 1, # the maximum number of values that can be selected by the users + options = [ # the list of options from which users can choose, a required field + discord.SelectOption( + label=name, + ) for name in TranslationModel.get_all_country_names() + ] + ) + async def select_callback(self, select, interaction): # the function called when the user is done selecting options + try: + response = await self.translation_model.send_translate_request(self.message.content, TranslationModel.get_country_code_from_name(select.values[0])) + await self.message.reply(mention_author=False, embed=build_translation_embed(self.message.content, response)) + await self.selection_message.delete() + except aiohttp.ClientResponseError as e: + await interaction.response.send_message(f"There was an error with the DeepL API: {e.message}", ephemeral=True, delete_after=15) + return + except Exception as e: + await interaction.response.send_message(f"There was an error: {e}", ephemeral=True, delete_after=15) + return diff --git a/gpt3discord.py b/gpt3discord.py index 4c36c48..5ffbfea 100644 --- a/gpt3discord.py +++ b/gpt3discord.py @@ -13,6 +13,8 @@ from cogs.image_service_cog import DrawDallEService from cogs.prompt_optimizer_cog import ImgPromptOptimizer from cogs.moderations_service_cog import ModerationsService from cogs.commands import Commands +from cogs.translation_service_cog import TranslationService +from models.deepl_model import TranslationModel from services.pinecone_service import PineconeService from services.deletion_service import Deletion @@ -23,7 +25,7 @@ from services.environment_service import EnvService from models.openai_model import Model -__version__ = "7.1" +__version__ = "8.0" if sys.platform == "win32": @@ -56,7 +58,6 @@ if PINECONE_TOKEN: pinecone_service = PineconeService(pinecone.Index(PINECONE_INDEX)) print("Got the pinecone service") - # # Message queueing for the debug service, defer debug messages to be sent later so we don't hit rate limits. # @@ -146,6 +147,10 @@ async def main(): ) ) + if EnvService.get_deepl_token(): + bot.add_cog(TranslationService(bot, TranslationModel())) + print("The translation service is enabled.") + bot.add_cog( Commands( bot, @@ -157,9 +162,11 @@ async def main(): bot.get_cog("DrawDallEService"), bot.get_cog("ImgPromptOptimizer"), bot.get_cog("ModerationsService"), + bot.get_cog("TranslationService"), ) ) + apply_multicog(bot) await bot.start(os.getenv("DISCORD_TOKEN")) diff --git a/models/autocomplete_model.py b/models/autocomplete_model.py index 63d14a3..2078bf1 100644 --- a/models/autocomplete_model.py +++ b/models/autocomplete_model.py @@ -3,6 +3,8 @@ import os import re import discord + +from models.deepl_model import TranslationModel from services.usage_service import UsageService from models.openai_model import Model from services.environment_service import EnvService @@ -72,6 +74,17 @@ class Settings_autocompleter: if channel.name.startswith(ctx.value.lower()) ] +class Translations_autocompleter: + """autocompleter for the translations command""" + + async def get_languages(ctx: discord.AutocompleteContext): + """gets valid values for the language option""" + return [ + language + for language in TranslationModel.get_all_country_names() + if language.lower().startswith(ctx.value.lower()) + ] + class File_autocompleter: """Autocompleter for the opener command""" diff --git a/models/check_model.py b/models/check_model.py index c717ebf..e2a10a4 100644 --- a/models/check_model.py +++ b/models/check_model.py @@ -6,6 +6,7 @@ from typing import Callable ADMIN_ROLES = EnvService.get_admin_roles() DALLE_ROLES = EnvService.get_dalle_roles() GPT_ROLES = EnvService.get_gpt_roles() +TRANSLATOR_ROLES = EnvService.get_translator_roles() ALLOWED_GUILDS = EnvService.get_allowed_guilds() @@ -61,3 +62,20 @@ class Check: return True return inner + + @staticmethod + def check_translator_roles() -> Callable: + async def inner(ctx: discord.ApplicationContext): + if TRANSLATOR_ROLES == [None]: + return True + if not any(role.name.lower() in TRANSLATOR_ROLES for role in ctx.user.roles): + await ctx.defer(ephemeral=True) + await ctx.respond( + f"You don't have permission, list of roles is {TRANSLATOR_ROLES}", + ephemeral=True, + delete_after=10, + ) + return False + return True + + return inner diff --git a/models/deepl_model.py b/models/deepl_model.py new file mode 100644 index 0000000..f2f693c --- /dev/null +++ b/models/deepl_model.py @@ -0,0 +1,98 @@ +import os +import traceback + +import aiohttp +import backoff + +COUNTRY_CODES = { + "BG": "Bulgarian", + "CS": "Czech", + "DA": "Danish", + "DE": "German", + "EL": "Greek", + "EN": "English", + "ES": "Spanish", + "FI": "Finnish", + "FR": "French", + "HU": "Hungarian", + "ID": "Indonesian", + "IT": "Italian", + "JA": "Japanese", + "LT": "Lithuanian", + "LV": "Latvian", + "NL": "Dutch", + "PL": "Polish", + "PT": "Portuguese", + "RO": "Romanian", + "RU": "Russian", + "SK": "Slovak", + "SV": "Swedish", + "TR": "Turkish", + "UK": "Ukrainian", + "ZH": "Chinese (simplified)", +} +class TranslationModel: + + def __init__(self): + self.deepl_token = os.getenv("DEEPL_TOKEN") + + def backoff_handler(details): + print( + f"Backing off {details['wait']:0.1f} seconds after {details['tries']} tries calling function {details['target']} | " + f"{details['exception'].status}: {details['exception'].message}" + ) + + @backoff.on_exception( + backoff.expo, + aiohttp.ClientResponseError, + factor=3, + base=5, + max_tries=4, + on_backoff=backoff_handler, + ) + async def send_translate_request(self, text, translate_language): + print("The text is: ", text) + print("The language is: ", translate_language) + print("The token is ", self.deepl_token) + async with aiohttp.ClientSession(raise_for_status=True) as session: + payload = { + "text": text, + "target_lang": translate_language, + } + # Instead of sending as json, we want to send as regular post params + headers = { + "Authorization": f"DeepL-Auth-Key {self.deepl_token}", + } + async with session.post( + "https://api-free.deepl.com/v2/translate", params=payload, headers=headers + ) as resp: + response = await resp.json() + print(response) + + try: + return response["translations"][0]["text"] + except Exception: + print(response) + traceback.print_exc() + return response + @staticmethod + def get_all_country_names(lower=False): + """Get a list of all the country names""" + return list(COUNTRY_CODES.values()) if not lower else [name.lower() for name in COUNTRY_CODES.values()] + + @staticmethod + def get_all_country_codes(): + """Get a list of all the country codes""" + return list(COUNTRY_CODES.keys()) + + @staticmethod + def get_country_name_from_code(code): + """Get the country name from the code""" + return COUNTRY_CODES[code] + + @staticmethod + def get_country_code_from_name(name): + """Get the country code from the name""" + for code, country_name in COUNTRY_CODES.items(): + if country_name.lower().strip() == name.lower().strip(): + return code \ No newline at end of file diff --git a/services/environment_service.py b/services/environment_service.py index 701b587..38340fe 100644 --- a/services/environment_service.py +++ b/services/environment_service.py @@ -138,6 +138,32 @@ class EnvService: ) return dalle_roles + @staticmethod + def get_translator_roles(): + # DALLE_ROLES is a comma separated list of string roles + # It can also just be one role + # Read these allowed roles and return as a list of strings + try: + translator_roles = os.getenv("TRANSLATOR_ROLES") + except Exception: + translator_roles = None + + if translator_roles is None: + print( + "TRANSLATOR_ROLES is not defined properly in the environment file!" + "Please copy your server's role and put it into TRANSLATOR in the .env file." + 'For example a line should look like: `TRANSLATOR="Translate"`' + ) + print("Defaulting to allowing all users to use Translator commands...") + return [None] + + translator_roles = ( + translator_roles.lower().split(",") + if "," in translator_roles + else [translator_roles.lower()] + ) + return translator_roles + @staticmethod def get_gpt_roles(): # GPT_ROLES is a comma separated list of string roles @@ -204,3 +230,11 @@ class EnvService: return Path(user_key_db_path) except Exception: return None + + @staticmethod + def get_deepl_token(): + try: + deepl_token = os.getenv("DEEPL_TOKEN") + return deepl_token + except Exception: + return None