diff --git a/README.md b/README.md index 2d8dd2a..af20462 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # translate -A [maubot](https://github.com/maubot/maubot) to translate words using Google Translate (DeepL is planned too) +A [maubot](https://github.com/maubot/maubot) to translate words using [Google +Translate](https://translate.google.com/about/) or +[LibreTranslate](https://github.com/LibreTranslate/LibreTranslate) +(Deepl is planned too) ## Usage @@ -19,15 +22,14 @@ You can also use the alias `tr`: !tr en ru Hello world. The first parameter (source language) can be set to `auto` or omitted entirely -to let Google Translate detect the source language. Additionally, you can reply +to let the bot detect the source language. Additionally, you can reply to a message with `!tr ` (no text) to translate the message you replied to. ## supported languages: -- de: (german) -- en: (english) -- zh: (chinese) -- ... +Since this depends on which translation provider you choose and can even vary from +one LibreTranslate instance to another, you can use the following command to get +all the supported languages and their codes: -Full list of supported languages: https://cloud.google.com/translate/docs/languages + !languages diff --git a/base-config.yaml b/base-config.yaml index 3cfbb5f..338123a 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,7 +1,10 @@ # Translation provider settings provider: id: google - args: {} + args: { + url: 'translate.example.com', # required if you use libretranslate, else irrelevant + api_key: 'loremipsumdolorsitamen1234' # optional if you use libretranslate, else irrelevant + } auto_translate: - room_id: '!roomid:example.com' main_language: en diff --git a/translate/bot.py b/translate/bot.py index f67cbee..3472c70 100644 --- a/translate/bot.py +++ b/translate/bot.py @@ -38,14 +38,14 @@ class TranslatorBot(Plugin): async def start(self) -> None: await super().start() - self.on_external_config_update() + await self.on_external_config_update() - def on_external_config_update(self) -> None: + async def on_external_config_update(self) -> None: self.translator = None self.config.load_and_update() self.auto_translate = self.config.load_auto_translate() try: - self.translator = self.config.load_translator() + self.translator = await self.config.load_translator() except TranslationProviderError: self.log.exception("Error loading translator") @@ -84,7 +84,8 @@ def is_acceptable(lang: str) -> bool: async def command_handler(self, evt: MessageEvent, language: Optional[Tuple[str, str]], text: str) -> None: if not language: - await evt.reply("Usage: !translate [from] [text or reply to message]") + await evt.reply("No supported target language detected. " + "Usage: !translate [from] [text or reply to message]") return if not self.config["response_reply"]: evt.disable_reply = True @@ -95,7 +96,13 @@ async def command_handler(self, evt: MessageEvent, language: Optional[Tuple[str, reply_evt = await self.client.get_event(evt.room_id, evt.content.get_reply_to()) text = reply_evt.content.body if not text: - await evt.reply("Usage: !translate [from] [text or reply to message]") + await evt.reply("Nothing to translate detected. Usage: !translate [from] [text or reply to message]") return result = await self.translator.translate(text, to_lang=language[1], from_lang=language[0]) await evt.reply(result.text) + + @command.new("languages") + async def languages_command_handler(self, evt: MessageEvent) -> None: + await evt.reply( + ", ".join([f"{name} `{code}`" for code, name in self.translator.get_supported_languages().items()]) + ) diff --git a/translate/provider/abstract.py b/translate/provider/abstract.py index e5839be..f4ba526 100644 --- a/translate/provider/abstract.py +++ b/translate/provider/abstract.py @@ -23,6 +23,8 @@ class AbstractTranslationProvider(ABC): @abstractmethod def __init__(self, args: Dict) -> None: pass + async def post_init(self) -> None: + pass @abstractmethod async def translate(self, text: str, to_lang: str, from_lang: str = "auto") -> Result: @@ -35,3 +37,6 @@ def is_supported_language(self, code: str) -> bool: @abstractmethod def get_language_name(self, code: str) -> str: pass + @abstractmethod + def get_supported_languages(self) -> dict: + pass diff --git a/translate/provider/deepl.py b/translate/provider/deepl.py index 02ab8c9..39e600e 100644 --- a/translate/provider/deepl.py +++ b/translate/provider/deepl.py @@ -126,5 +126,8 @@ def is_supported_language(self, code: str) -> bool: def get_language_name(self, code: str) -> str: return self.supported_languages[code] + def get_supported_languages(self) -> dict: + return self.supported_languages + make_translation_provider = DeepLTranslate diff --git a/translate/provider/google.py b/translate/provider/google.py index 7923bca..6325dab 100644 --- a/translate/provider/google.py +++ b/translate/provider/google.py @@ -76,5 +76,8 @@ def is_supported_language(self, code: str) -> bool: def get_language_name(self, code: str) -> str: return self.supported_languages[code] + def get_supported_languages(self) -> dict: + return self.supported_languages + make_translation_provider = GoogleTranslate diff --git a/translate/provider/libretranslate.py b/translate/provider/libretranslate.py new file mode 100644 index 0000000..5fbd1be --- /dev/null +++ b/translate/provider/libretranslate.py @@ -0,0 +1,74 @@ +# translate - A maubot plugin to translate words. +# Copyright (C) 2022 Tammes Burghard +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import json +import re +from typing import Dict + +from aiohttp import ClientSession, client_exceptions +from yarl import URL + +from . import AbstractTranslationProvider, Result + + +class LibreTranslate(AbstractTranslationProvider): + headers: Dict[str, str] = {"Content-Type": "application/json"} + + def __init__(self, args: Dict) -> None: + super().__init__(args) + if args.get("url") is None: + raise ValueError("Please specify the url of your preferred libretranslate instance in provider.args.url") + else: + self._base_url = args.get("url") + if not re.match(r"^https?://", self._base_url): + self._base_url = "https://" + self._base_url + self.url: URL = URL(self._base_url + "/translate") + self.api_key = args.get("api_key") + self.supported_languages = {"auto": "Detect language"} + + async def post_init(self): + try: + async with ClientSession() as sess: + resp = await sess.get(self._base_url + "/languages") + for lang in await resp.json(): + self.supported_languages[lang["code"]] = lang["name"] + except client_exceptions.ClientError: + raise ValueError(f"This url ({self._base_url}) does not point to a compatible libretranslate instance. " + f"Please change it") + + async def translate(self, text: str, to_lang: str, from_lang: str = "auto") -> Result: + if not from_lang: + from_lang = "auto" + async with ClientSession() as sess: + data=json.dumps({"q": text, "source": from_lang, "target": to_lang, + "format": "text", "api_key": self.api_key}) + resp = await sess.post(self.url, data=data, headers=self.headers) + if resp.status == 403: + raise ValueError("Request forbidden. You did probably configure an incorrect api key.") + data = await resp.json() + return Result(text=data["translatedText"], + source_language=data["detectedLanguage"]["language"] if from_lang == "auto" else from_lang) + + def is_supported_language(self, code: str) -> bool: + return code.lower() in self.supported_languages.keys() + + def get_language_name(self, code: str) -> str: + return self.supported_languages[code] + + def get_supported_languages(self) -> dict: + return self.supported_languages + + +make_translation_provider = LibreTranslate diff --git a/translate/util.py b/translate/util.py index 438b459..34dcf0d 100644 --- a/translate/util.py +++ b/translate/util.py @@ -41,7 +41,7 @@ def do_update(self, helper: ConfigUpdateHelper) -> None: helper.copy("auto_translate") helper.copy("response_reply") - def load_translator(self) -> AbstractTranslationProvider: + async def load_translator(self) -> AbstractTranslationProvider: try: provider = self["provider.id"] mod = import_module(f".{provider}", "translate.provider") @@ -49,7 +49,9 @@ def load_translator(self) -> AbstractTranslationProvider: except (KeyError, AttributeError, ImportError) as e: raise TranslationProviderError("Failed to load translation provider") from e try: - return make(self["provider.args"]) + translation_provider = make(self["provider.args"]) + await translation_provider.post_init() + return translation_provider except Exception as e: raise TranslationProviderError("Failed to initialize translation provider") from e