From 002c608d4515ccb38c88fcbeb5d0d28fb3fe8743 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 15:04:31 +0500 Subject: [PATCH 01/13] i18n class was added --- telebot/util.py | 113 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 96 insertions(+), 17 deletions(-) diff --git a/telebot/util.py b/telebot/util.py index 24f6b55..ebf7466 100644 --- a/telebot/util.py +++ b/telebot/util.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import gettext +import os import random import re import string @@ -21,6 +23,7 @@ try: # noinspection PyPackageRequirements from PIL import Image from io import BytesIO + pil_imported = True except: pil_imported = False @@ -32,23 +35,26 @@ logger = logging.getLogger('TeleBot') thread_local = threading.local() content_type_media = [ - 'text', 'audio', 'animation', 'document', 'photo', 'sticker', 'video', 'video_note', 'voice', 'contact', 'dice', 'poll', + 'text', 'audio', 'animation', 'document', 'photo', 'sticker', 'video', 'video_note', 'voice', 'contact', 'dice', + 'poll', 'venue', 'location' ] content_type_service = [ - 'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo', 'delete_chat_photo', 'group_chat_created', + 'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo', 'delete_chat_photo', + 'group_chat_created', 'supergroup_chat_created', 'channel_chat_created', 'migrate_to_chat_id', 'migrate_from_chat_id', 'pinned_message', - 'proximity_alert_triggered', 'voice_chat_scheduled', 'voice_chat_started', 'voice_chat_ended', + 'proximity_alert_triggered', 'voice_chat_scheduled', 'voice_chat_started', 'voice_chat_ended', 'voice_chat_participants_invited', 'message_auto_delete_timer_changed' ] update_types = [ - "update_id", "message", "edited_message", "channel_post", "edited_channel_post", "inline_query", - "chosen_inline_result", "callback_query", "shipping_query", "pre_checkout_query", "poll", "poll_answer", + "update_id", "message", "edited_message", "channel_post", "edited_channel_post", "inline_query", + "chosen_inline_result", "callback_query", "shipping_query", "pre_checkout_query", "poll", "poll_answer", "my_chat_member", "chat_member", "chat_join_request" ] + class WorkerThread(threading.Thread): count = 0 @@ -177,7 +183,7 @@ class AsyncTask: class CustomRequestResponse(): - def __init__(self, json_text, status_code = 200, reason = ""): + def __init__(self, json_text, status_code=200, reason=""): self.status_code = status_code self.text = json_text self.reason = reason @@ -217,7 +223,7 @@ def pil_image_to_file(image, extension='JPEG', quality='web_low'): photoBuffer = BytesIO() image.convert('RGB').save(photoBuffer, extension, quality=quality) photoBuffer.seek(0) - + return photoBuffer else: raise RuntimeError('PIL module is not imported') @@ -280,7 +286,7 @@ def split_string(text: str, chars_per_string: int) -> List[str]: return [text[i:i + chars_per_string] for i in range(0, len(text), chars_per_string)] -def smart_split(text: str, chars_per_string: int=MAX_MESSAGE_LENGTH) -> List[str]: +def smart_split(text: str, chars_per_string: int = MAX_MESSAGE_LENGTH) -> List[str]: """ Splits one string into multiple strings, with a maximum amount of `chars_per_string` characters per string. This is very useful for splitting one giant message into multiples. @@ -305,9 +311,12 @@ def smart_split(text: str, chars_per_string: int=MAX_MESSAGE_LENGTH) -> List[str part = text[:chars_per_string] - if "\n" in part: part = _text_before_last("\n") - elif ". " in part: part = _text_before_last(". ") - elif " " in part: part = _text_before_last(" ") + if "\n" in part: + part = _text_before_last("\n") + elif ". " in part: + part = _text_before_last(". ") + elif " " in part: + part = _text_before_last(" ") parts.append(part) text = text[len(part):] @@ -325,7 +334,7 @@ def escape(text: str) -> str: return text -def user_link(user: types.User, include_id: bool=False) -> str: +def user_link(user: types.User, include_id: bool = False) -> str: """ Returns an HTML user link. This is useful for reports. Attention: Don't forget to set parse_mode to 'HTML'! @@ -338,11 +347,11 @@ def user_link(user: types.User, include_id: bool=False) -> str: :return: HTML user link """ name = escape(user.first_name) - return (f"{name}" - + (f" (
{user.id}
)" if include_id else "")) + return (f"{name}" + + (f" (
{user.id}
)" if include_id else "")) -def quick_markup(values: Dict[str, Dict[str, Any]], row_width: int=2) -> types.InlineKeyboardMarkup: +def quick_markup(values: Dict[str, Dict[str, Any]], row_width: int = 2) -> types.InlineKeyboardMarkup: """ Returns a reply markup from a dict in this format: {'text': kwargs} This is useful to avoid always typing 'btn1 = InlineKeyboardButton(...)' 'btn2 = InlineKeyboardButton(...)' @@ -443,22 +452,26 @@ def generate_random_token(): return ''.join(random.sample(string.ascii_letters, 16)) -def deprecated(warn: bool=True, alternative: Optional[Callable]=None): +def deprecated(warn: bool = True, alternative: Optional[Callable] = None): """ Use this decorator to mark functions as deprecated. When the function is used, an info (or warning if `warn` is True) is logged. :param warn: If True a warning is logged else an info :param alternative: The new function to use instead """ + def decorator(function): def wrapper(*args, **kwargs): - info = f"`{function.__name__}` is deprecated." + (f" Use `{alternative.__name__}` instead" if alternative else "") + info = f"`{function.__name__}` is deprecated." + ( + f" Use `{alternative.__name__}` instead" if alternative else "") if not warn: logger.info(info) else: logger.warning(info) return function(*args, **kwargs) + return wrapper + return decorator @@ -477,6 +490,7 @@ def webhook_google_functions(bot, request): else: return 'Bot ON' + def antiflood(function, *args, **kwargs): """ Use this function inside loops in order to avoid getting TooManyRequests error. @@ -499,3 +513,68 @@ def antiflood(function, *args, **kwargs): msg = function(*args, **kwargs) finally: return msg + + +def find_translations(path, domain): + """ + Looks for translations with passed 'domain' in passed 'path' + """ + if not os.path.exists(path): + raise RuntimeError(f"Translations directory by path: {path!r} was not found") + + result = {} + + for name in os.listdir(path): + translations_path = os.path.join(path, name, 'LC_MESSAGES') + + if not os.path.isdir(translations_path): + continue + + po_file = os.path.join(translations_path, domain + '.po') + mo_file = po_file[:-2] + 'mo' + + if os.path.isfile(po_file) and not os.path.isfile(mo_file): + raise FileNotFoundError(f"Translations for: {name!r} were not compiled!") + + with open(mo_file, 'rb') as file: + result[name] = gettext.GNUTranslations(file) + + return result + + +class I18N: + """ + This class provides high-level tool for internationalization + It is based on gettext util. + """ + + def __init__(self, translations_path, domain_name: str): + self.path = translations_path + self.domain = domain_name + self.translations = find_translations(self.path, self.domain) + + @property + def available_translations(self): + return list(self.translations) + + def gettext(self, text: str, lang: str = None): + """ + Singular translations + """ + if not lang or lang not in self.translations: + return text + + translator = self.translations[lang] + return translator.gettext(text) + + def ngettext(self, singular: str, plural: str, lang: str = None, n=1): + """ + Plural translations + """ + if not lang or lang not in self.translations: + if n == 1: + return singular + return plural + + translator = self.translations[lang] + return translator.ngettext(singular, plural, n) From 0d85a345519406a5fdc311c41c191757cd51805d Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 15:07:46 +0500 Subject: [PATCH 02/13] an example for i18n class was added --- examples/i18n_class_example/keyboards.py | 23 +++ .../locales/en/LC_MESSAGES/messages.po | 51 ++++++ .../locales/ru/LC_MESSAGES/messages.po | 60 +++++++ .../locales/uz_Latn/LC_MESSAGES/messages.po | 58 +++++++ examples/i18n_class_example/main.py | 146 ++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 examples/i18n_class_example/keyboards.py create mode 100644 examples/i18n_class_example/locales/en/LC_MESSAGES/messages.po create mode 100644 examples/i18n_class_example/locales/ru/LC_MESSAGES/messages.po create mode 100644 examples/i18n_class_example/locales/uz_Latn/LC_MESSAGES/messages.po create mode 100644 examples/i18n_class_example/main.py diff --git a/examples/i18n_class_example/keyboards.py b/examples/i18n_class_example/keyboards.py new file mode 100644 index 0000000..4a94268 --- /dev/null +++ b/examples/i18n_class_example/keyboards.py @@ -0,0 +1,23 @@ +from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton + + +def languages_keyboard(): + return InlineKeyboardMarkup( + keyboard=[ + [ + InlineKeyboardButton(text="English", callback_data='en'), + InlineKeyboardButton(text="Русский", callback_data='ru'), + InlineKeyboardButton(text="O'zbekcha", callback_data='uz_Latn') + ] + ] + ) + + +def clicker_keyboard(_, lang): + return InlineKeyboardMarkup( + keyboard=[ + [ + InlineKeyboardButton(text=_("click", lang=lang), callback_data='click'), + ] + ] + ) diff --git a/examples/i18n_class_example/locales/en/LC_MESSAGES/messages.po b/examples/i18n_class_example/locales/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000..fc6565a --- /dev/null +++ b/examples/i18n_class_example/locales/en/LC_MESSAGES/messages.po @@ -0,0 +1,51 @@ +# English translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-02-18 17:54+0500\n" +"PO-Revision-Date: 2022-02-18 16:22+0500\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: keyboards.py:20 +msgid "click" +msgstr "" + +#: main.py:78 +msgid "" +"Hello, {user_fist_name}!\n" +"This is the example of multilanguage bot.\n" +"Available commands:\n" +"\n" +"/lang - change your language\n" +"/plural - pluralization example" +msgstr "" + +#: main.py:102 +msgid "Language has been changed" +msgstr "" + +#: main.py:114 +#, fuzzy +msgid "You have {number} click" +msgid_plural "You have {number} clicks" +msgstr[0] "" +msgstr[1] "" + +#: main.py:120 +msgid "" +"This is clicker.\n" +"\n" +msgstr "" + diff --git a/examples/i18n_class_example/locales/ru/LC_MESSAGES/messages.po b/examples/i18n_class_example/locales/ru/LC_MESSAGES/messages.po new file mode 100644 index 0000000..1733ef1 --- /dev/null +++ b/examples/i18n_class_example/locales/ru/LC_MESSAGES/messages.po @@ -0,0 +1,60 @@ +# Russian translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-02-18 17:54+0500\n" +"PO-Revision-Date: 2022-02-18 16:22+0500\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: keyboards.py:20 +msgid "click" +msgstr "Клик" + +#: main.py:78 +msgid "" +"Hello, {user_fist_name}!\n" +"This is the example of multilanguage bot.\n" +"Available commands:\n" +"\n" +"/lang - change your language\n" +"/plural - pluralization example" +msgstr "" +"Привет, {user_fist_name}!\n" +"Это пример мультиязычного бота.\n" +"Доступные команды:\n" +"\n" +"/lang - изменить язык\n" +"/plural - пример плюрализации" + +#: main.py:102 +msgid "Language has been changed" +msgstr "Язык был сменён" + +#: main.py:114 +msgid "You have {number} click" +msgid_plural "You have {number} clicks" +msgstr[0] "У вас {number} клик" +msgstr[1] "У вас {number} клика" +msgstr[2] "У вас {number} кликов" + +#: main.py:120 +msgid "" +"This is clicker.\n" +"\n" +msgstr "" +"Это кликер.\n" +"\n" + diff --git a/examples/i18n_class_example/locales/uz_Latn/LC_MESSAGES/messages.po b/examples/i18n_class_example/locales/uz_Latn/LC_MESSAGES/messages.po new file mode 100644 index 0000000..e694b78 --- /dev/null +++ b/examples/i18n_class_example/locales/uz_Latn/LC_MESSAGES/messages.po @@ -0,0 +1,58 @@ +# Uzbek (Latin) translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-02-18 17:54+0500\n" +"PO-Revision-Date: 2022-02-18 16:22+0500\n" +"Last-Translator: FULL NAME \n" +"Language: uz_Latn\n" +"Language-Team: uz_Latn \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: keyboards.py:20 +msgid "click" +msgstr "clik" + +#: main.py:78 +msgid "" +"Hello, {user_fist_name}!\n" +"This is the example of multilanguage bot.\n" +"Available commands:\n" +"\n" +"/lang - change your language\n" +"/plural - pluralization example" +msgstr "" +"Salom, {user_fist_name}!\n" +"Bu multilanguage bot misoli.\n" +"Mavjud buyruqlar:\n" +"\n" +"/lang - tilni ozgartirish\n" +"/plural - pluralizatsiya misoli" + +#: main.py:102 +msgid "Language has been changed" +msgstr "Til ozgartirildi" + +#: main.py:114 +msgid "You have {number} click" +msgid_plural "You have {number} clicks" +msgstr[0] "Sizda {number}ta clik" +msgstr[1] "Sizda {number}ta clik" + +#: main.py:120 +msgid "" +"This is clicker.\n" +"\n" +msgstr "" +"Bu clicker.\n" +"\n" + diff --git a/examples/i18n_class_example/main.py b/examples/i18n_class_example/main.py new file mode 100644 index 0000000..aca71c2 --- /dev/null +++ b/examples/i18n_class_example/main.py @@ -0,0 +1,146 @@ +""" +In this example you will learn how to adapt your bot to different languages +Using built-in class I18N. + +You need to install babel package 'https://pypi.org/project/Babel/' +Babel provides a command-line interface for working with message catalogs +After installing babel package you have a script called 'pybabel' +Too see all the commands open terminal and type 'pybabel --help' +Full description for pybabel commands can be found here: 'https://babel.pocoo.org/en/latest/cmdline.html' + +Create a directory 'locales' where our translations will be stored + +First we need to extract texts: + pybabel extract -o locales/{domain_name}.pot --input-dirs . +{domain_name}.pot - is the file where all translations are saved +The name of this file should be the same as domain which you pass to I18N class +In this example domain_name will be 'messages' + +For gettext (singular texts) we use '_' alias and it works perfect +You may also you some alias for ngettext (plural texts) but you can face with a problem that +your plural texts are not being extracted +That is because by default 'pybabel extract' recognizes the following keywords: + _, gettext, ngettext, ugettext, ungettext, dgettext, dngettext, N_ +To add your own keyword you can use '-k' flag +In this example for 'ngettext' i will assign double underscore alias '__' + +Full command with pluralization support will look so: + pybabel extract -o locales/{domain_name}.pot -k __:1,2 --input-dirs . + +Then create directories with translations (get list of all locales: 'pybabel --list-locales'): + pybabel init -i locales/{domain_name}.pot -d locales -l en + pybabel init -i locales/{domain_name}.pot -d locales -l ru + pybabel init -i locales/{domain_name}.pot -d locales -l uz_Latn + +Now you can translate the texts located in locales/{language}/LC_MESSAGES/{domain_name}.po +After you translated all the texts you need to compile .po files: + pybabel compile -d locales + +When you delete/update your texts you also need to update them in .po files: + pybabel extract -o locales/{domain_name}.pot -k __:1,2 --input-dirs . + pybabel update -i locales/{domain_name}.pot -d locales + - translate + pybabel compile -d locales +""" + +from functools import wraps +import keyboards +from telebot import TeleBot, types, custom_filters +from telebot.storage.memory_storage import StateMemoryStorage +from telebot.util import I18N + +storage = StateMemoryStorage() +bot = TeleBot("", state_storage=storage) + +i18n = I18N(translations_path='locales', domain_name='messages') +_ = i18n.gettext # for singular translations +__ = i18n.ngettext # for plural translations + +# These are example storages, do not use it in a production development +users_lang = {} +users_clicks = {} + + +def get_user_language(func): + """ + This decorator will pass to your handler current user's language + """ + @wraps(func) + def inner(*args, **kwargs): + obj = args[0] + kwargs.update(lang=users_lang.get(obj.from_user.id, 'en')) + return func(*args, **kwargs) + + return inner + + +@bot.message_handler(commands='start') +@get_user_language +def start_handler(message: types.Message, lang): + text = _("Hello, {user_fist_name}!\n" + "This is the example of multilanguage bot.\n" + "Available commands:\n\n" + "/lang - change your language\n" + "/plural - pluralization example", lang=lang) + + # remember don't use f string for interpolation, use .format method instead + text = text.format(user_fist_name=message.from_user.first_name) + bot.send_message(message.from_user.id, text) + + +@bot.message_handler(commands='lang') +def change_language_handler(message: types.Message): + bot.send_message(message.chat.id, "Choose language\nВыберите язык\nTilni tanlang", + reply_markup=keyboards.languages_keyboard()) + + +@bot.callback_query_handler(func=None, text=custom_filters.TextFilter(contains=['en', 'ru', 'uz_Latn'])) +def language_handler(call: types.CallbackQuery): + lang = call.data + users_lang[call.from_user.id] = lang + + bot.edit_message_text(_("Language has been changed", lang=lang), call.from_user.id, call.message.id) + bot.delete_state(call.from_user.id) + + +@bot.message_handler(commands='plural') +@get_user_language +def pluralization_handler(message: types.Message, lang): + if not users_clicks.get(message.from_user.id): + users_clicks[message.from_user.id] = 0 + clicks = users_clicks[message.from_user.id] + + text = __( + singular="You have {number} click", + plural="You have {number} clicks", + n=clicks, + lang=lang + ) + text = _("This is clicker.\n\n", lang=lang) + text.format(number=clicks) + bot.send_message(message.chat.id, text, reply_markup=keyboards.clicker_keyboard(_, lang)) + + +@bot.callback_query_handler(func=None, text=custom_filters.TextFilter(equals='click')) +@get_user_language +def click_handler(call: types.CallbackQuery, lang): + if not users_clicks.get(call.from_user.id): + users_clicks[call.from_user.id] = 1 + else: + users_clicks[call.from_user.id] += 1 + + clicks = users_clicks[call.from_user.id] + + text = __( + singular="You have {number} click", + plural="You have {number} clicks", + n=clicks, + lang=lang + ) + text = _("This is clicker.\n\n", lang=lang) + text.format(number=clicks) + bot.edit_message_text(text, call.from_user.id, call.message.message_id, + reply_markup=keyboards.clicker_keyboard(_, lang)) + + +if __name__ == '__main__': + bot.add_custom_filter(custom_filters.TextMatchFilter()) + bot.infinity_polling() From ae5d183db077bc87179647653def52df3d1a45fb Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 15:53:58 +0500 Subject: [PATCH 03/13] slight TextFilter class improvement --- telebot/asyncio_filters.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/telebot/asyncio_filters.py b/telebot/asyncio_filters.py index cb0120b..232870a 100644 --- a/telebot/asyncio_filters.py +++ b/telebot/asyncio_filters.py @@ -92,39 +92,33 @@ class TextFilter: if self.ignore_case: text = text.lower() - - if self.equals: - self.equals = self.equals.lower() - elif self.contains: - self.contains = tuple(map(str.lower, self.contains)) - elif self.starts_with: - self.starts_with = tuple(map(str.lower, self.starts_with)) - elif self.ends_with: - self.ends_with = tuple(map(str.lower, self.ends_with)) + prepare_func = lambda string: str(string).lower() + else: + prepare_func = str if self.equals: - result = self.equals == text + result = prepare_func(self.equals) == text if result: return True elif not result and not any((self.contains, self.starts_with, self.ends_with)): return False if self.contains: - result = any([i in text for i in self.contains]) + result = any([prepare_func(i) in text for i in self.contains]) if result: return True elif not result and not any((self.starts_with, self.ends_with)): return False if self.starts_with: - result = any([text.startswith(i) for i in self.starts_with]) + result = any([text.startswith(prepare_func(i)) for i in self.starts_with]) if result: return True elif not result and not self.ends_with: return False if self.ends_with: - return any([text.endswith(i) for i in self.ends_with]) + return any([text.endswith(prepare_func(i)) for i in self.ends_with]) return False From 5337d4838d1b9d0774cdb8755f76549111944316 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 16:02:14 +0500 Subject: [PATCH 04/13] asyncio_middlewares.py was created && BaseMiddleware class was replaced to asyncio_middlewares.py --- .../middleware/flooding_middleware.py | 2 +- examples/asynchronous_telebot/middleware/i18n.py | 3 ++- telebot/asyncio_handler_backends.py | 15 --------------- telebot/asyncio_middlewares.py | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 telebot/asyncio_middlewares.py diff --git a/examples/asynchronous_telebot/middleware/flooding_middleware.py b/examples/asynchronous_telebot/middleware/flooding_middleware.py index b8f589e..761190d 100644 --- a/examples/asynchronous_telebot/middleware/flooding_middleware.py +++ b/examples/asynchronous_telebot/middleware/flooding_middleware.py @@ -1,6 +1,6 @@ # Just a little example of middleware handlers -from telebot.asyncio_handler_backends import BaseMiddleware +from telebot.asyncio_middlewares import BaseMiddleware from telebot.async_telebot import AsyncTeleBot from telebot.async_telebot import CancelUpdate bot = AsyncTeleBot('TOKEN') diff --git a/examples/asynchronous_telebot/middleware/i18n.py b/examples/asynchronous_telebot/middleware/i18n.py index 81281bc..cbbd82b 100644 --- a/examples/asynchronous_telebot/middleware/i18n.py +++ b/examples/asynchronous_telebot/middleware/i18n.py @@ -7,6 +7,7 @@ # But this example just to show the work of middlewares. import telebot +import telebot.asyncio_middlewares from telebot.async_telebot import AsyncTeleBot from telebot import asyncio_handler_backends import logging @@ -27,7 +28,7 @@ TRANSLATIONS = { bot = AsyncTeleBot('TOKEN') -class LanguageMiddleware(asyncio_handler_backends.BaseMiddleware): +class LanguageMiddleware(telebot.asyncio_middlewares.BaseMiddleware): def __init__(self): self.update_types = ['message'] # Update types that will be handled by this middleware. async def pre_process(self, message, data): diff --git a/telebot/asyncio_handler_backends.py b/telebot/asyncio_handler_backends.py index 1784481..51ed603 100644 --- a/telebot/asyncio_handler_backends.py +++ b/telebot/asyncio_handler_backends.py @@ -1,18 +1,3 @@ -class BaseMiddleware: - """ - Base class for middleware. - - Your middlewares should be inherited from this class. - """ - def __init__(self): - pass - - async def pre_process(self, message, data): - raise NotImplementedError - async def post_process(self, message, data, exception): - raise NotImplementedError - - class State: def __init__(self) -> None: self.name = None diff --git a/telebot/asyncio_middlewares.py b/telebot/asyncio_middlewares.py new file mode 100644 index 0000000..cf33280 --- /dev/null +++ b/telebot/asyncio_middlewares.py @@ -0,0 +1,15 @@ +class BaseMiddleware: + """ + Base class for middleware. + + Your middlewares should be inherited from this class. + """ + + def __init__(self): + pass + + async def pre_process(self, message, data): + raise NotImplementedError + + async def post_process(self, message, data, exception): + raise NotImplementedError From 1f6e60fd7433b5200ef947dce4dd48dd89d73a49 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 16:25:46 +0500 Subject: [PATCH 05/13] I18N middleware implementation was added --- telebot/asyncio_middlewares.py | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/telebot/asyncio_middlewares.py b/telebot/asyncio_middlewares.py index cf33280..d5d0e4a 100644 --- a/telebot/asyncio_middlewares.py +++ b/telebot/asyncio_middlewares.py @@ -1,3 +1,15 @@ +import contextvars + +try: + from babel.support import LazyProxy + + babel_imported = True +except ImportError: + babel_imported = False + +from telebot import util + + class BaseMiddleware: """ Base class for middleware. @@ -13,3 +25,85 @@ class BaseMiddleware: async def post_process(self, message, data, exception): raise NotImplementedError + + +class I18N(BaseMiddleware): + """ + This middleware provides high-level tool for internationalization + It is based on gettext util. + """ + + context_lang = contextvars.ContextVar('language', default=None) + + def __init__(self, translations_path, domain_name: str): + super().__init__() + self.update_types = self.process_update_types() + + self.path = translations_path + self.domain = domain_name + self.translations = util.find_translations(self.path, self.domain) + + @property + def available_translations(self): + return list(self.translations) + + def gettext(self, text: str, lang: str = None): + """ + Singular translations + """ + + if lang is None: + lang = self.context_lang.get() + + if lang not in self.translations: + return text + + translator = self.translations[lang] + return translator.gettext(text) + + def ngettext(self, singular: str, plural: str, lang: str = None, n=1): + """ + Plural translations + """ + if lang is None: + lang = self.context_lang.get() + + if lang not in self.translations: + if n == 1: + return singular + return plural + + translator = self.translations[lang] + return translator.ngettext(singular, plural, n) + + def lazy_gettext(self, text: str, lang: str = None): + if not babel_imported: + raise RuntimeError('babel module is not imported. Check that you installed it.') + return LazyProxy(self.gettext, text, lang, enable_cache=False) + + def lazy_ngettext(self, singular: str, plural: str, lang: str = None, n=1): + if not babel_imported: + raise RuntimeError('babel module is not imported. Check that you installed it.') + return LazyProxy(self.ngettext, singular, plural, lang, n, enable_cache=False) + + async def get_user_language(self, obj): + """ + You need to override this method and return user language + """ + raise NotImplementedError + + def process_update_types(self) -> list: + """ + You need to override this method and return any update types which you want to be processed + """ + raise NotImplementedError + + async def pre_process(self, message, data): + """ + context language variable will be set each time when update from 'process_update_types' comes + value is the result of 'get_user_language' method + """ + self.context_lang.set(await self.get_user_language(obj=message)) + + async def post_process(self, message, data, exception): + pass From 93b97fc3feab593c10a838053cf7eddb580d5cb2 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 18:47:36 +0500 Subject: [PATCH 06/13] I18N middleware example was added --- .../i18n_middleware_example/keyboards.py | 34 +++ .../locales/en/LC_MESSAGES/messages.po | 81 +++++++ .../locales/ru/LC_MESSAGES/messages.po | 82 +++++++ .../locales/uz_Latn/LC_MESSAGES/messages.po | 80 +++++++ .../i18n_middleware_example/main.py | 208 ++++++++++++++++++ 5 files changed, 485 insertions(+) create mode 100644 examples/asynchronous_telebot/middleware/i18n_middleware_example/keyboards.py create mode 100644 examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/en/LC_MESSAGES/messages.po create mode 100644 examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/ru/LC_MESSAGES/messages.po create mode 100644 examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/uz_Latn/LC_MESSAGES/messages.po create mode 100644 examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/keyboards.py b/examples/asynchronous_telebot/middleware/i18n_middleware_example/keyboards.py new file mode 100644 index 0000000..14d0473 --- /dev/null +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/keyboards.py @@ -0,0 +1,34 @@ +from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton, ReplyKeyboardMarkup, KeyboardButton + + +def languages_keyboard(): + return InlineKeyboardMarkup( + keyboard=[ + [ + InlineKeyboardButton(text="English", callback_data='en'), + InlineKeyboardButton(text="Русский", callback_data='ru'), + InlineKeyboardButton(text="O'zbekcha", callback_data='uz_Latn') + ] + ] + ) + + +def clicker_keyboard(_): + return InlineKeyboardMarkup( + keyboard=[ + [ + InlineKeyboardButton(text=_("click"), callback_data='click'), + ] + ] + ) + + +def menu_keyboard(_): + keyboard = ReplyKeyboardMarkup(resize_keyboard=True) + keyboard.add( + KeyboardButton(text=_("My user id")), + KeyboardButton(text=_("My user name")), + KeyboardButton(text=_("My first name")) + ) + + return keyboard diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/en/LC_MESSAGES/messages.po b/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000..2cf8eef --- /dev/null +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/en/LC_MESSAGES/messages.po @@ -0,0 +1,81 @@ +# English translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-02-19 18:37+0500\n" +"PO-Revision-Date: 2022-02-18 16:22+0500\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: keyboards.py:20 +msgid "click" +msgstr "" + +#: keyboards.py:29 +msgid "My user id" +msgstr "" + +#: keyboards.py:30 +msgid "My user name" +msgstr "" + +#: keyboards.py:31 +msgid "My first name" +msgstr "" + +#: main.py:97 +msgid "" +"Hello, {user_fist_name}!\n" +"This is the example of multilanguage bot.\n" +"Available commands:\n" +"\n" +"/lang - change your language\n" +"/plural - pluralization example\n" +"/menu - text menu example" +msgstr "" + +#: main.py:121 +msgid "Language has been changed" +msgstr "" + +#: main.py:130 main.py:150 +#, fuzzy +msgid "You have {number} click" +msgid_plural "You have {number} clicks" +msgstr[0] "" +msgstr[1] "" + +#: main.py:135 main.py:155 +msgid "" +"This is clicker.\n" +"\n" +msgstr "" + +#: main.py:163 +msgid "This is ReplyKeyboardMarkup menu example in multilanguage bot." +msgstr "" + +#: main.py:203 +msgid "Seems you confused language" +msgstr "" + +#~ msgid "" +#~ "Hello, {user_fist_name}!\n" +#~ "This is the example of multilanguage bot.\n" +#~ "Available commands:\n" +#~ "\n" +#~ "/lang - change your language\n" +#~ "/plural - pluralization example" +#~ msgstr "" + diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/ru/LC_MESSAGES/messages.po b/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/ru/LC_MESSAGES/messages.po new file mode 100644 index 0000000..6d330b0 --- /dev/null +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/ru/LC_MESSAGES/messages.po @@ -0,0 +1,82 @@ +# Russian translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-02-19 18:37+0500\n" +"PO-Revision-Date: 2022-02-18 16:22+0500\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: keyboards.py:20 +msgid "click" +msgstr "Клик" + +#: keyboards.py:29 +msgid "My user id" +msgstr "Мой user id" + +#: keyboards.py:30 +msgid "My user name" +msgstr "Мой user name" + +#: keyboards.py:31 +msgid "My first name" +msgstr "Мой first name" + +#: main.py:97 +msgid "" +"Hello, {user_fist_name}!\n" +"This is the example of multilanguage bot.\n" +"Available commands:\n" +"\n" +"/lang - change your language\n" +"/plural - pluralization example\n" +"/menu - text menu example" +msgstr "" +"Привет, {user_fist_name}!\n" +"Это пример мультиязычного бота.\n" +"Доступные команды:\n" +"\n" +"/lang - изменить язык\n" +"/plural - пример плюрализации\n" +"/menu - Пример текстового меню" + +#: main.py:121 +msgid "Language has been changed" +msgstr "Язык был сменён" + +#: main.py:130 main.py:150 +msgid "You have {number} click" +msgid_plural "You have {number} clicks" +msgstr[0] "У вас {number} клик" +msgstr[1] "У вас {number} клика" +msgstr[2] "У вас {number} кликов" + +#: main.py:135 main.py:155 +msgid "" +"This is clicker.\n" +"\n" +msgstr "" +"Это кликер.\n" +"\n" + +#: main.py:163 +msgid "This is ReplyKeyboardMarkup menu example in multilanguage bot." +msgstr "Это пример ReplyKeyboardMarkup меню в мультиязычном боте." + +#: main.py:203 +msgid "Seems you confused language" +msgstr "Кажется, вы перепутали язык" + diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/uz_Latn/LC_MESSAGES/messages.po b/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/uz_Latn/LC_MESSAGES/messages.po new file mode 100644 index 0000000..c5bd000 --- /dev/null +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/locales/uz_Latn/LC_MESSAGES/messages.po @@ -0,0 +1,80 @@ +# Uzbek (Latin) translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-02-19 18:37+0500\n" +"PO-Revision-Date: 2022-02-18 16:22+0500\n" +"Last-Translator: FULL NAME \n" +"Language: uz_Latn\n" +"Language-Team: uz_Latn \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: keyboards.py:20 +msgid "click" +msgstr "clik" + +#: keyboards.py:29 +msgid "My user id" +msgstr "Mani user id" + +#: keyboards.py:30 +msgid "My user name" +msgstr "Mani user name" + +#: keyboards.py:31 +msgid "My first name" +msgstr "Mani first name" + +#: main.py:97 +msgid "" +"Hello, {user_fist_name}!\n" +"This is the example of multilanguage bot.\n" +"Available commands:\n" +"\n" +"/lang - change your language\n" +"/plural - pluralization example\n" +"/menu - text menu example" +msgstr "" +"Salom, {user_fist_name}!\n" +"Bu multilanguage bot misoli.\n" +"Mavjud buyruqlar:\n" +"\n" +"/lang - tilni ozgartirish\n" +"/plural - pluralizatsiya misoli\n" +"/menu - text menu misoli" + +#: main.py:121 +msgid "Language has been changed" +msgstr "Til ozgartirildi" + +#: main.py:130 main.py:150 +msgid "You have {number} click" +msgid_plural "You have {number} clicks" +msgstr[0] "Sizda {number}ta clik" +msgstr[1] "Sizda {number}ta clik" + +#: main.py:135 main.py:155 +msgid "" +"This is clicker.\n" +"\n" +msgstr "" +"Bu clicker.\n" +"\n" + +#: main.py:163 +msgid "This is ReplyKeyboardMarkup menu example in multilanguage bot." +msgstr "Bu multilanguage bot da replykeyboardmarkup menyu misoli." + +#: main.py:203 +msgid "Seems you confused language" +msgstr "Tilni adashtirdiz" + diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py new file mode 100644 index 0000000..2cee2c7 --- /dev/null +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py @@ -0,0 +1,208 @@ +""" +In this example you will learn how to adapt your bot to different languages +Using built-in middleware I18N. + +You need to install babel package 'https://pypi.org/project/Babel/' +Babel provides a command-line interface for working with message catalogs +After installing babel package you have a script called 'pybabel' +Too see all the commands open terminal and type 'pybabel --help' +Full description for pybabel commands can be found here: 'https://babel.pocoo.org/en/latest/cmdline.html' + +Create a directory 'locales' where our translations will be stored + +First we need to extract texts: + pybabel extract -o locales/{domain_name}.pot --input-dirs . +{domain_name}.pot - is the file where all translations are saved +The name of this file should be the same as domain which you pass to I18N class +In this example domain_name will be 'messages' + +For gettext (singular texts) we use '_' alias and it works perfect +You may also you some alias for ngettext (plural texts) but you can face with a problem that +your plural texts are not being extracted +That is because by default 'pybabel extract' recognizes the following keywords: + _, gettext, ngettext, ugettext, ungettext, dgettext, dngettext, N_ +To add your own keyword you can use '-k' flag +In this example for 'ngettext' i will assign double underscore alias '__' + +Full command with pluralization support will look so: + pybabel extract -o locales/{domain_name}.pot -k __:1,2 --input-dirs . + +Then create directories with translations (get list of all locales: 'pybabel --list-locales'): + pybabel init -i locales/{domain_name}.pot -d locales -l en + pybabel init -i locales/{domain_name}.pot -d locales -l ru + pybabel init -i locales/{domain_name}.pot -d locales -l uz_Latn + +Now you can translate the texts located in locales/{language}/LC_MESSAGES/{domain_name}.po +After you translated all the texts you need to compile .po files: + pybabel compile -d locales + +When you delete/update your texts you also need to update them in .po files: + pybabel extract -o locales/{domain_name}.pot -k __:1,2 --input-dirs . + pybabel update -i locales/{domain_name}.pot -d locales + - translate + pybabel compile -d locales +""" + +import asyncio +from typing import Union + +import keyboards +from telebot import types +from telebot.async_telebot import AsyncTeleBot +from telebot.asyncio_filters import TextMatchFilter, TextFilter +from telebot.asyncio_middlewares import I18N +from telebot.asyncio_storage.memory_storage import StateMemoryStorage + + +class I18NMiddleware(I18N): + + def process_update_types(self) -> list: + """ + Here you need to return a list of update types which you want to be processed + """ + return ['message', 'callback_query'] + + async def get_user_language(self, obj: Union[types.Message, types.CallbackQuery]): + """ + This method is called when new update comes (only updates which you return in 'process_update_types' method) + Returned language will be used in 'pre_process' method of parent class + Returned language will be set to context language variable. + If you need to get translation with user's actual language you don't have to pass it manually + It will be automatically passed from context language value. + However if you need some other language you can always pass it. + """ + + user_id = obj.from_user.id + + if user_id not in users_lang: + users_lang[user_id] = 'en' + + return users_lang[user_id] + + +storage = StateMemoryStorage() +bot = AsyncTeleBot("1254795383:AAE7gbj1aas4lEDHB1eVuZZhSGPWcH1B5ds", state_storage=storage) + +i18n = I18NMiddleware(translations_path='locales', domain_name='messages') +_ = i18n.gettext # for singular translations +__ = i18n.ngettext # for plural translations + +# These are example storages, do not use it in a production development +users_lang = {} +users_clicks = {} + + +@bot.message_handler(commands='start') +async def start_handler(message: types.Message): + text = _("Hello, {user_fist_name}!\n" + "This is the example of multilanguage bot.\n" + "Available commands:\n\n" + "/lang - change your language\n" + "/plural - pluralization example\n" + "/menu - text menu example") + + # remember don't use f string for interpolation, use .format method instead + text = text.format(user_fist_name=message.from_user.first_name) + await bot.send_message(message.from_user.id, text) + + +@bot.message_handler(commands='lang') +async def change_language_handler(message: types.Message): + await bot.send_message(message.chat.id, "Choose language\nВыберите язык\nTilni tanlang", + reply_markup=keyboards.languages_keyboard()) + + +@bot.callback_query_handler(func=None, text=TextFilter(contains=['en', 'ru', 'uz_Latn'])) +async def language_handler(call: types.CallbackQuery): + lang = call.data + users_lang[call.from_user.id] = lang + + # When you changed user language, you have to pass it manually beacause it is not changed in context + await bot.edit_message_text(_("Language has been changed", lang=lang), call.from_user.id, call.message.id) + + +@bot.message_handler(commands='plural') +async def pluralization_handler(message: types.Message): + if not users_clicks.get(message.from_user.id): + users_clicks[message.from_user.id] = 0 + clicks = users_clicks[message.from_user.id] + + text = __( + singular="You have {number} click", + plural="You have {number} clicks", + n=clicks + ) + text = _("This is clicker.\n\n") + text.format(number=clicks) + + await bot.send_message(message.chat.id, text, reply_markup=keyboards.clicker_keyboard(_)) + + +@bot.callback_query_handler(func=None, text=TextFilter(equals='click')) +async def click_handler(call: types.CallbackQuery): + if not users_clicks.get(call.from_user.id): + users_clicks[call.from_user.id] = 1 + else: + users_clicks[call.from_user.id] += 1 + + clicks = users_clicks[call.from_user.id] + + text = __( + singular="You have {number} click", + plural="You have {number} clicks", + n=clicks + ) + text = _("This is clicker.\n\n") + text.format(number=clicks) + + await bot.edit_message_text(text, call.from_user.id, call.message.message_id, + reply_markup=keyboards.clicker_keyboard(_)) + + +@bot.message_handler(commands='menu') +async def menu_handler(message: types.Message): + text = _("This is ReplyKeyboardMarkup menu example in multilanguage bot.") + await bot.send_message(message.chat.id, text, reply_markup=keyboards.menu_keyboard(_)) + + +# For lazy tranlations +# lazy gettext is used when you don't know user's locale +# It can be used for example to handle text buttons in multilanguage bot +# The actual translation will be delayed until update comes and context language is set +l_ = i18n.lazy_gettext + + +# Handlers below will handle text according to user's language +@bot.message_handler(text=l_("My user id")) +async def return_user_id(message: types.Message): + await bot.send_message(message.chat.id, str(message.from_user.id)) + + +@bot.message_handler(text=l_("My user name")) +async def return_user_id(message: types.Message): + username = message.from_user.username + if not username: + username = '-' + await bot.send_message(message.chat.id, username) + + +# You can make it case insensitive +@bot.message_handler(text=TextFilter(equals=l_("My first name"), ignore_case=True)) +async def return_user_id(message: types.Message): + await bot.send_message(message.chat.id, message.from_user.first_name) + + +all_menu_texts = [] +for language in i18n.available_translations: + for menu_text in ("My user id", "My user name", "My first name"): + all_menu_texts.append(_(menu_text, language)) + + +# When user confused language. (handles all menu buttons texts) +@bot.message_handler(text=TextFilter(contains=all_menu_texts, ignore_case=True)) +async def missed_message(message: types.Message): + await bot.send_message(message.chat.id, _("Seems you confused language"), reply_markup=keyboards.menu_keyboard(_)) + + +if __name__ == '__main__': + bot.middlewares.append(i18n) + bot.add_custom_filter(TextMatchFilter()) + asyncio.run(bot.infinity_polling()) From 10b5886dcc20b2beb5ff498726bf85e79d03ea0c Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 18:56:27 +0500 Subject: [PATCH 07/13] Completed I18N examples descriptions --- .../middleware/i18n_middleware_example/main.py | 8 +++++++- examples/i18n_class_example/main.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py index 2cee2c7..34cfc01 100644 --- a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py @@ -41,6 +41,12 @@ When you delete/update your texts you also need to update them in .po files: pybabel update -i locales/{domain_name}.pot -d locales - translate pybabel compile -d locales + +If you have any exceptions check: + - you have installed babel + - translations are ready, so you just compiled it + - in the commands above you replaced {domain_name} to messages + - you are writing commands from correct path in terminal """ import asyncio @@ -81,7 +87,7 @@ class I18NMiddleware(I18N): storage = StateMemoryStorage() -bot = AsyncTeleBot("1254795383:AAE7gbj1aas4lEDHB1eVuZZhSGPWcH1B5ds", state_storage=storage) +bot = AsyncTeleBot("", state_storage=storage) i18n = I18NMiddleware(translations_path='locales', domain_name='messages') _ = i18n.gettext # for singular translations diff --git a/examples/i18n_class_example/main.py b/examples/i18n_class_example/main.py index aca71c2..7071847 100644 --- a/examples/i18n_class_example/main.py +++ b/examples/i18n_class_example/main.py @@ -41,6 +41,12 @@ When you delete/update your texts you also need to update them in .po files: pybabel update -i locales/{domain_name}.pot -d locales - translate pybabel compile -d locales + +If you have any exceptions check: + - you have installed babel + - translations are ready, so you just compiled it + - in the commands above you replaced {domain_name} to messages + - you are writing commands from correct path in terminal """ from functools import wraps From 9bfc0b2c6f85228b51ee5dba0ffb3991d79e47d8 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 23:37:03 +0500 Subject: [PATCH 08/13] preventet breaking change --- telebot/asyncio_handler_backends.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/telebot/asyncio_handler_backends.py b/telebot/asyncio_handler_backends.py index 51ed603..866c5ed 100644 --- a/telebot/asyncio_handler_backends.py +++ b/telebot/asyncio_handler_backends.py @@ -1,9 +1,13 @@ +from telebot.asyncio_middlewares import BaseMiddleware + + class State: def __init__(self) -> None: self.name = None + def __str__(self) -> str: return self.name - + class StatesGroup: def __init_subclass__(cls) -> None: From 9b20f41ece7688f4c3f961ecf1616252d56e03b3 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sat, 19 Feb 2022 23:57:21 +0500 Subject: [PATCH 09/13] I18N class removed from telebot.util.py --- examples/i18n_class_example/i18n_class.py | 66 +++++++++++++++ examples/i18n_class_example/main.py | 3 +- telebot/util.py | 98 +++-------------------- 3 files changed, 80 insertions(+), 87 deletions(-) create mode 100644 examples/i18n_class_example/i18n_class.py diff --git a/examples/i18n_class_example/i18n_class.py b/examples/i18n_class_example/i18n_class.py new file mode 100644 index 0000000..aa02612 --- /dev/null +++ b/examples/i18n_class_example/i18n_class.py @@ -0,0 +1,66 @@ +import gettext +import os + + +class I18N: + """ + This class provides high-level tool for internationalization + It is based on gettext util. + """ + + def __init__(self, translations_path, domain_name: str): + self.path = translations_path + self.domain = domain_name + self.translations = self.find_translations() + + @property + def available_translations(self): + return list(self.translations) + + def gettext(self, text: str, lang: str = None): + """ + Singular translations + """ + if not lang or lang not in self.translations: + return text + + translator = self.translations[lang] + return translator.gettext(text) + + def ngettext(self, singular: str, plural: str, lang: str = None, n=1): + """ + Plural translations + """ + if not lang or lang not in self.translations: + if n == 1: + return singular + return plural + + translator = self.translations[lang] + return translator.ngettext(singular, plural, n) + + def find_translations(self): + """ + Looks for translations with passed 'domain' in passed 'path' + """ + if not os.path.exists(self.path): + raise RuntimeError(f"Translations directory by path: {self.path!r} was not found") + + result = {} + + for name in os.listdir(self.path): + translations_path = os.path.join(self.path, name, 'LC_MESSAGES') + + if not os.path.isdir(translations_path): + continue + + po_file = os.path.join(translations_path, self.domain + '.po') + mo_file = po_file[:-2] + 'mo' + + if os.path.isfile(po_file) and not os.path.isfile(mo_file): + raise FileNotFoundError(f"Translations for: {name!r} were not compiled!") + + with open(mo_file, 'rb') as file: + result[name] = gettext.GNUTranslations(file) + + return result diff --git a/examples/i18n_class_example/main.py b/examples/i18n_class_example/main.py index 7071847..23794f2 100644 --- a/examples/i18n_class_example/main.py +++ b/examples/i18n_class_example/main.py @@ -53,7 +53,7 @@ from functools import wraps import keyboards from telebot import TeleBot, types, custom_filters from telebot.storage.memory_storage import StateMemoryStorage -from telebot.util import I18N +from i18n_class import I18N storage = StateMemoryStorage() bot = TeleBot("", state_storage=storage) @@ -71,6 +71,7 @@ def get_user_language(func): """ This decorator will pass to your handler current user's language """ + @wraps(func) def inner(*args, **kwargs): obj = args[0] diff --git a/telebot/util.py b/telebot/util.py index ebf7466..ae5d8bd 100644 --- a/telebot/util.py +++ b/telebot/util.py @@ -35,14 +35,12 @@ logger = logging.getLogger('TeleBot') thread_local = threading.local() content_type_media = [ - 'text', 'audio', 'animation', 'document', 'photo', 'sticker', 'video', 'video_note', 'voice', 'contact', 'dice', - 'poll', + 'text', 'audio', 'animation', 'document', 'photo', 'sticker', 'video', 'video_note', 'voice', 'contact', 'dice', 'poll', 'venue', 'location' ] content_type_service = [ - 'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo', 'delete_chat_photo', - 'group_chat_created', + 'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo', 'delete_chat_photo', 'group_chat_created', 'supergroup_chat_created', 'channel_chat_created', 'migrate_to_chat_id', 'migrate_from_chat_id', 'pinned_message', 'proximity_alert_triggered', 'voice_chat_scheduled', 'voice_chat_started', 'voice_chat_ended', 'voice_chat_participants_invited', 'message_auto_delete_timer_changed' @@ -183,7 +181,7 @@ class AsyncTask: class CustomRequestResponse(): - def __init__(self, json_text, status_code=200, reason=""): + def __init__(self, json_text, status_code = 200, reason = ""): self.status_code = status_code self.text = json_text self.reason = reason @@ -286,7 +284,7 @@ def split_string(text: str, chars_per_string: int) -> List[str]: return [text[i:i + chars_per_string] for i in range(0, len(text), chars_per_string)] -def smart_split(text: str, chars_per_string: int = MAX_MESSAGE_LENGTH) -> List[str]: +def smart_split(text: str, chars_per_string: int=MAX_MESSAGE_LENGTH) -> List[str]: """ Splits one string into multiple strings, with a maximum amount of `chars_per_string` characters per string. This is very useful for splitting one giant message into multiples. @@ -311,12 +309,9 @@ def smart_split(text: str, chars_per_string: int = MAX_MESSAGE_LENGTH) -> List[s part = text[:chars_per_string] - if "\n" in part: - part = _text_before_last("\n") - elif ". " in part: - part = _text_before_last(". ") - elif " " in part: - part = _text_before_last(" ") + if "\n" in part: part = _text_before_last("\n") + elif ". " in part: part = _text_before_last(". ") + elif " " in part: part = _text_before_last(" ") parts.append(part) text = text[len(part):] @@ -334,7 +329,7 @@ def escape(text: str) -> str: return text -def user_link(user: types.User, include_id: bool = False) -> str: +def user_link(user: types.User, include_id: bool=False) -> str: """ Returns an HTML user link. This is useful for reports. Attention: Don't forget to set parse_mode to 'HTML'! @@ -348,10 +343,10 @@ def user_link(user: types.User, include_id: bool = False) -> str: """ name = escape(user.first_name) return (f"{name}" - + (f" (
{user.id}
)" if include_id else "")) + + (f" (
{user.id}
)" if include_id else "")) -def quick_markup(values: Dict[str, Dict[str, Any]], row_width: int = 2) -> types.InlineKeyboardMarkup: +def quick_markup(values: Dict[str, Dict[str, Any]], row_width: int=2) -> types.InlineKeyboardMarkup: """ Returns a reply markup from a dict in this format: {'text': kwargs} This is useful to avoid always typing 'btn1 = InlineKeyboardButton(...)' 'btn2 = InlineKeyboardButton(...)' @@ -452,26 +447,22 @@ def generate_random_token(): return ''.join(random.sample(string.ascii_letters, 16)) -def deprecated(warn: bool = True, alternative: Optional[Callable] = None): +def deprecated(warn: bool=True, alternative: Optional[Callable]=None): """ Use this decorator to mark functions as deprecated. When the function is used, an info (or warning if `warn` is True) is logged. :param warn: If True a warning is logged else an info :param alternative: The new function to use instead """ - def decorator(function): def wrapper(*args, **kwargs): - info = f"`{function.__name__}` is deprecated." + ( - f" Use `{alternative.__name__}` instead" if alternative else "") + info = f"`{function.__name__}` is deprecated." + (f" Use `{alternative.__name__}` instead" if alternative else "") if not warn: logger.info(info) else: logger.warning(info) return function(*args, **kwargs) - return wrapper - return decorator @@ -513,68 +504,3 @@ def antiflood(function, *args, **kwargs): msg = function(*args, **kwargs) finally: return msg - - -def find_translations(path, domain): - """ - Looks for translations with passed 'domain' in passed 'path' - """ - if not os.path.exists(path): - raise RuntimeError(f"Translations directory by path: {path!r} was not found") - - result = {} - - for name in os.listdir(path): - translations_path = os.path.join(path, name, 'LC_MESSAGES') - - if not os.path.isdir(translations_path): - continue - - po_file = os.path.join(translations_path, domain + '.po') - mo_file = po_file[:-2] + 'mo' - - if os.path.isfile(po_file) and not os.path.isfile(mo_file): - raise FileNotFoundError(f"Translations for: {name!r} were not compiled!") - - with open(mo_file, 'rb') as file: - result[name] = gettext.GNUTranslations(file) - - return result - - -class I18N: - """ - This class provides high-level tool for internationalization - It is based on gettext util. - """ - - def __init__(self, translations_path, domain_name: str): - self.path = translations_path - self.domain = domain_name - self.translations = find_translations(self.path, self.domain) - - @property - def available_translations(self): - return list(self.translations) - - def gettext(self, text: str, lang: str = None): - """ - Singular translations - """ - if not lang or lang not in self.translations: - return text - - translator = self.translations[lang] - return translator.gettext(text) - - def ngettext(self, singular: str, plural: str, lang: str = None, n=1): - """ - Plural translations - """ - if not lang or lang not in self.translations: - if n == 1: - return singular - return plural - - translator = self.translations[lang] - return translator.ngettext(singular, plural, n) From 74e9780b30cff65a5f28c8bc18a1a06db16ce9bd Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sun, 20 Feb 2022 00:08:14 +0500 Subject: [PATCH 10/13] BaseMiddleware returned to it's original place && I18N middleware is now only in examples --- .../middleware/flooding_middleware.py | 2 +- .../asynchronous_telebot/middleware/i18n.py | 3 +- .../i18n_base_midddleware.py | 51 +++++++++++-------- .../i18n_middleware_example/main.py | 4 +- telebot/asyncio_handler_backends.py | 17 ++++++- 5 files changed, 50 insertions(+), 27 deletions(-) rename telebot/asyncio_middlewares.py => examples/asynchronous_telebot/middleware/i18n_middleware_example/i18n_base_midddleware.py (72%) diff --git a/examples/asynchronous_telebot/middleware/flooding_middleware.py b/examples/asynchronous_telebot/middleware/flooding_middleware.py index 761190d..b8f589e 100644 --- a/examples/asynchronous_telebot/middleware/flooding_middleware.py +++ b/examples/asynchronous_telebot/middleware/flooding_middleware.py @@ -1,6 +1,6 @@ # Just a little example of middleware handlers -from telebot.asyncio_middlewares import BaseMiddleware +from telebot.asyncio_handler_backends import BaseMiddleware from telebot.async_telebot import AsyncTeleBot from telebot.async_telebot import CancelUpdate bot = AsyncTeleBot('TOKEN') diff --git a/examples/asynchronous_telebot/middleware/i18n.py b/examples/asynchronous_telebot/middleware/i18n.py index cbbd82b..81281bc 100644 --- a/examples/asynchronous_telebot/middleware/i18n.py +++ b/examples/asynchronous_telebot/middleware/i18n.py @@ -7,7 +7,6 @@ # But this example just to show the work of middlewares. import telebot -import telebot.asyncio_middlewares from telebot.async_telebot import AsyncTeleBot from telebot import asyncio_handler_backends import logging @@ -28,7 +27,7 @@ TRANSLATIONS = { bot = AsyncTeleBot('TOKEN') -class LanguageMiddleware(telebot.asyncio_middlewares.BaseMiddleware): +class LanguageMiddleware(asyncio_handler_backends.BaseMiddleware): def __init__(self): self.update_types = ['message'] # Update types that will be handled by this middleware. async def pre_process(self, message, data): diff --git a/telebot/asyncio_middlewares.py b/examples/asynchronous_telebot/middleware/i18n_middleware_example/i18n_base_midddleware.py similarity index 72% rename from telebot/asyncio_middlewares.py rename to examples/asynchronous_telebot/middleware/i18n_middleware_example/i18n_base_midddleware.py index d5d0e4a..c850288 100644 --- a/telebot/asyncio_middlewares.py +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/i18n_base_midddleware.py @@ -1,4 +1,8 @@ import contextvars +import gettext +import os + +from telebot.asyncio_handler_backends import BaseMiddleware try: from babel.support import LazyProxy @@ -7,25 +11,6 @@ try: except ImportError: babel_imported = False -from telebot import util - - -class BaseMiddleware: - """ - Base class for middleware. - - Your middlewares should be inherited from this class. - """ - - def __init__(self): - pass - - async def pre_process(self, message, data): - raise NotImplementedError - - async def post_process(self, message, data, exception): - raise NotImplementedError - class I18N(BaseMiddleware): """ @@ -41,7 +26,7 @@ class I18N(BaseMiddleware): self.path = translations_path self.domain = domain_name - self.translations = util.find_translations(self.path, self.domain) + self.translations = self.find_translations() @property def available_translations(self): @@ -107,3 +92,29 @@ class I18N(BaseMiddleware): async def post_process(self, message, data, exception): pass + + def find_translations(self): + """ + Looks for translations with passed 'domain' in passed 'path' + """ + if not os.path.exists(self.path): + raise RuntimeError(f"Translations directory by path: {self.path!r} was not found") + + result = {} + + for name in os.listdir(self.path): + translations_path = os.path.join(self.path, name, 'LC_MESSAGES') + + if not os.path.isdir(translations_path): + continue + + po_file = os.path.join(translations_path, self.domain + '.po') + mo_file = po_file[:-2] + 'mo' + + if os.path.isfile(po_file) and not os.path.isfile(mo_file): + raise FileNotFoundError(f"Translations for: {name!r} were not compiled!") + + with open(mo_file, 'rb') as file: + result[name] = gettext.GNUTranslations(file) + + return result diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py index 34cfc01..c4cd54c 100644 --- a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py @@ -56,7 +56,7 @@ import keyboards from telebot import types from telebot.async_telebot import AsyncTeleBot from telebot.asyncio_filters import TextMatchFilter, TextFilter -from telebot.asyncio_middlewares import I18N +from i18n_base_midddleware import I18N from telebot.asyncio_storage.memory_storage import StateMemoryStorage @@ -87,7 +87,7 @@ class I18NMiddleware(I18N): storage = StateMemoryStorage() -bot = AsyncTeleBot("", state_storage=storage) +bot = AsyncTeleBot("1254795383:AAE7gbj1aas4lEDHB1eVuZZhSGPWcH1B5ds", state_storage=storage) i18n = I18NMiddleware(translations_path='locales', domain_name='messages') _ = i18n.gettext # for singular translations diff --git a/telebot/asyncio_handler_backends.py b/telebot/asyncio_handler_backends.py index 866c5ed..c6db03b 100644 --- a/telebot/asyncio_handler_backends.py +++ b/telebot/asyncio_handler_backends.py @@ -1,4 +1,17 @@ -from telebot.asyncio_middlewares import BaseMiddleware +class BaseMiddleware: + """ + Base class for middleware. + Your middlewares should be inherited from this class. + """ + + def __init__(self): + pass + + async def pre_process(self, message, data): + raise NotImplementedError + + async def post_process(self, message, data, exception): + raise NotImplementedError class State: @@ -15,4 +28,4 @@ class StatesGroup: for name, value in cls.__dict__.items(): if not name.startswith('__') and not callable(value) and isinstance(value, State): # change value of that variable - value.name = ':'.join((cls.__name__, name)) + value.name = ':'.join((cls.__name__, name)) \ No newline at end of file From 5d7ae385ec32d7465a6011d7a10f8cff055e41d4 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sun, 20 Feb 2022 00:12:14 +0500 Subject: [PATCH 11/13] token removed. --- .../middleware/i18n_middleware_example/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py index c4cd54c..cf06c07 100644 --- a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py @@ -87,7 +87,7 @@ class I18NMiddleware(I18N): storage = StateMemoryStorage() -bot = AsyncTeleBot("1254795383:AAE7gbj1aas4lEDHB1eVuZZhSGPWcH1B5ds", state_storage=storage) +bot = AsyncTeleBot("", state_storage=storage) i18n = I18NMiddleware(translations_path='locales', domain_name='messages') _ = i18n.gettext # for singular translations From 38bff65cafbb183668fe775178191e83a88c3551 Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Sun, 20 Feb 2022 00:28:27 +0500 Subject: [PATCH 12/13] removed unused imports from util.py --- telebot/util.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/telebot/util.py b/telebot/util.py index ae5d8bd..bec0411 100644 --- a/telebot/util.py +++ b/telebot/util.py @@ -1,6 +1,4 @@ # -*- coding: utf-8 -*- -import gettext -import os import random import re import string From 7993e1d1c961123adf02bfe9bda1a8e4150e5eeb Mon Sep 17 00:00:00 2001 From: abdullaev388 Date: Mon, 21 Feb 2022 20:08:03 +0500 Subject: [PATCH 13/13] corrected setup middleware in async i18n middleware example --- .../middleware/i18n_middleware_example/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py index cf06c07..1faeaad 100644 --- a/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py +++ b/examples/asynchronous_telebot/middleware/i18n_middleware_example/main.py @@ -209,6 +209,6 @@ async def missed_message(message: types.Message): if __name__ == '__main__': - bot.middlewares.append(i18n) + bot.setup_middleware(i18n) bot.add_custom_filter(TextMatchFilter()) asyncio.run(bot.infinity_polling())