diff --git a/README.md b/README.md index 6cefedb..3db80b5 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,11 @@ 2. Возьмите файл `env_example` там же, переименуйте как `.env` (с точкой в начале), откройте и заполните переменные; 3. Запустите бота: `docker compose up -d` (или `docker-compose up -d` на старых версиях Docker); 4. Проверьте, что контейнер поднялся: `docker compose ps` + +## Локализация + +Если вы хотите изменить тексты в боте, ознакомьтесь с информацией в +[Wiki](https://github.com/MasterGroosha/telegram-feedback-bot/wiki). В настоящий момент поддерживается только +изменение текстов сообщений, но не описаний в меню команд + +Папку `bot/locales` в случае с развертыванием бота в Docker можно переопределить, подсунув её снаружи как volume. \ No newline at end of file diff --git a/bot/__main__.py b/bot/__main__.py index f5429c0..043625b 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -6,10 +6,10 @@ from aiogram.client.telegram import TelegramAPIServer from aiogram.webhook.aiohttp_server import SimpleRequestHandler from bot.handlers import setup_routers -# from aiogram.dispatcher.webhook import configure_app - -# from bot.configreader import load_config, Config +from fluent.runtime import FluentLocalization, FluentResourceLoader from bot.commandsworker import set_bot_commands +from bot.middlewares import L10nMiddleware +from pathlib import Path from bot.config_reader import config @@ -21,6 +21,13 @@ async def main(): format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", ) + # Получение пути до каталога locales относительно текущего файла + locales_dir = Path(__file__).parent.joinpath("locales") + # Создание объектов Fluent + # FluentResourceLoader использует фигурные скобки, поэтому f-strings здесь нельзя + l10n_loader = FluentResourceLoader(str(locales_dir) + "/{locale}") + l10n = FluentLocalization(["ru"], ["strings.ftl", "errors.ftl"], l10n_loader) + bot = Bot(token=config.bot_token.get_secret_value()) dp = Dispatcher() router = setup_routers() @@ -29,6 +36,9 @@ async def main(): if config.custom_bot_api: bot.session.api = TelegramAPIServer.from_base(config.custom_bot_api, is_local=True) + # Регистрация мидлварей + dp.message.middleware(L10nMiddleware(l10n)) + # Регистрация /-команд в интерфейсе await set_bot_commands(bot) diff --git a/bot/handlers/admin_no_reply.py b/bot/handlers/admin_no_reply.py index 6eeb84c..2fafe4f 100644 --- a/bot/handlers/admin_no_reply.py +++ b/bot/handlers/admin_no_reply.py @@ -1,5 +1,6 @@ from aiogram import Router, F from aiogram.types import ContentType, Message +from fluent.runtime import FluentLocalization from bot.config_reader import config @@ -8,12 +9,13 @@ @router.message(~F.reply_to_message) -async def has_no_reply(message: Message): +async def has_no_reply(message: Message, l10n: FluentLocalization): """ Хэндлер на сообщение от админа, не содержащее ответ (reply). В этом случае надо кинуть ошибку. :param message: сообщение от админа, не являющееся ответом на другое сообщение + :param l10n: объект локализации """ if message.content_type not in (ContentType.NEW_CHAT_MEMBERS, ContentType.LEFT_CHAT_MEMBER): - await message.reply("Это сообщение не является ответом на какое-либо другое!") + await message.reply(l10n.format_value("no-reply-error")) diff --git a/bot/handlers/adminmode.py b/bot/handlers/adminmode.py index 91dc42b..d9d8d95 100644 --- a/bot/handlers/adminmode.py +++ b/bot/handlers/adminmode.py @@ -1,7 +1,8 @@ from aiogram import Router, F, Bot -from aiogram.filters import Command from aiogram.exceptions import TelegramAPIError +from aiogram.filters import Command from aiogram.types import Message, Chat +from fluent.runtime import FluentLocalization from bot.config_reader import config @@ -10,6 +11,12 @@ def extract_id(message: Message) -> int: + """ + Извлекает ID юзера из хэштега в сообщении + + :param message: сообщение, из хэштега в котором нужно достать айди пользователя + :return: ID пользователя, извлечённый из хэштега в сообщении + """ # Получение списка сущностей (entities) из текста или подписи к медиафайлу в отвечаемом сообщении entities = message.entities or message.caption_entities # Если всё сделано верно, то последняя (или единственная) сущность должна быть хэштегом... @@ -25,7 +32,14 @@ def extract_id(message: Message) -> int: @router.message(Command(commands=["get", "who"]), F.reply_to_message) -async def get_user_info(message: Message, bot: Bot): +async def get_user_info(message: Message, bot: Bot, l10n: FluentLocalization): + """ + Обработчик команд /get и /who. Получает информацию о пользователе. + + :param message: объект сообщения, на которое админ ответил одной из команд выше + :param bot: объект бота, который обрабатывает текущий апдейт + :param l10n: объект локализации + """ def get_full_name(chat: Chat): if not chat.first_name: return "" @@ -41,19 +55,34 @@ def get_full_name(chat: Chat): try: user = await bot.get_chat(user_id) except TelegramAPIError as ex: - return await message.reply(f"Не удалось получить информацию о пользователе! Ошибка: {ex}") - - u = f"@{user.username}" if user.username else 'нет' - await message.reply(f"Имя: {get_full_name(user)}\n\nID: {user.id}\nUsername: {u}") + await message.reply( + l10n.format_value( + msg_id="cannot-get-user-info-error", + args={"error": ex.message}) + ) + return + + u = f"@{user.username}" if user.username else l10n.format_value("no") + await message.reply( + l10n.format_value( + msg_id="user-info", + args={ + "name": get_full_name(user), + "id": user.id, + "username": u + } + ) + ) @router.message(F.reply_to_message) -async def reply_to_user(message: Message): +async def reply_to_user(message: Message, l10n: FluentLocalization): """ Ответ администратора на сообщение юзера (отправленное ботом). Используется метод copy_message, поэтому ответить можно чем угодно, хоть опросом. :param message: сообщение от админа, являющееся ответом на другое сообщение + :param l10n: объект локализации """ # Вырезаем ID @@ -67,4 +96,8 @@ async def reply_to_user(message: Message): try: await message.copy_to(user_id) except TelegramAPIError as ex: - await message.reply(f"Не удалось отправить сообщение адресату!\nОтвет от Telegram: {ex.message}") + await message.reply( + l10n.format_value( + msg_id="cannot-answer-to-user-error", + args={"error": ex.message}) + ) diff --git a/bot/handlers/bans.py b/bot/handlers/bans.py index 7657255..6f18666 100644 --- a/bot/handlers/bans.py +++ b/bot/handlers/bans.py @@ -3,6 +3,7 @@ from aiogram import Router, F from aiogram.filters import Command from aiogram.types import Message +from fluent.runtime import FluentLocalization from bot.blocklists import banned, shadowbanned from bot.config_reader import config @@ -13,33 +14,37 @@ @router.message(Command(commands=["ban"]), F.reply_to_message) -async def cmd_ban(message: Message): +async def cmd_ban(message: Message, l10n: FluentLocalization): try: user_id = extract_id(message.reply_to_message) except ValueError as ex: return await message.reply(str(ex)) banned.add(int(user_id)) await message.reply( - f"ID {user_id} добавлен в список заблокированных. " - f"При попытке отправить сообщение пользователь получит уведомление о том, что заблокирован." + l10n.format_value( + msg_id="user-banned", + args={"id": user_id} + ) ) @router.message(Command(commands=["shadowban"]), F.reply_to_message) -async def cmd_shadowban(message: Message): +async def cmd_shadowban(message: Message, l10n: FluentLocalization): try: user_id = extract_id(message.reply_to_message) except ValueError as ex: return await message.reply(str(ex)) shadowbanned.add(int(user_id)) await message.reply( - f"ID {user_id} добавлен в список скрытно заблокированных. " - f"При попытке отправить сообщение пользователь не узнает, что заблокирован." + l10n.format_value( + msg_id="user-shadowbanned", + args={"id": user_id} + ) ) @router.message(Command(commands=["unban"]), F.reply_to_message) -async def cmd_unban(message: Message): +async def cmd_unban(message: Message, l10n: FluentLocalization): try: user_id = extract_id(message.reply_to_message) except ValueError as ex: @@ -49,22 +54,27 @@ async def cmd_unban(message: Message): banned.remove(user_id) with suppress(KeyError): shadowbanned.remove(user_id) - await message.reply(f"ID {user_id} разблокирован") + await message.reply( + l10n.format_value( + msg_id="user-unbanned", + args={"id": user_id} + ) + ) @router.message(Command(commands=["list_banned"])) -async def cmd_list_banned(message: Message): +async def cmd_list_banned(message: Message, l10n: FluentLocalization): has_bans = len(banned) > 0 or len(shadowbanned) > 0 if not has_bans: - await message.answer("Нет заблокированных пользователей") + await message.answer(l10n.format_value("no-banned")) return result = [] if len(banned) > 0: - result.append("Список заблокированных:") + result.append(l10n.format_value("list-banned-title")) for item in banned: result.append(f"• #id{item}") if len(shadowbanned) > 0: - result.append("\nСписок скрытно заблокированных:") + result.append('\n{}'.format(l10n.format_value("list-shadowbanned-title"))) for item in shadowbanned: result.append(f"• #id{item}") diff --git a/bot/handlers/message_edit.py b/bot/handlers/message_edit.py index fdfe499..b197aba 100644 --- a/bot/handlers/message_edit.py +++ b/bot/handlers/message_edit.py @@ -1,18 +1,19 @@ from aiogram import Router from aiogram.types import Message +from fluent.runtime import FluentLocalization router = Router() @router.edited_message() -async def edited_message_warning(message: Message): +async def edited_message_warning(message: Message, l10n: FluentLocalization): """ Хэндлер на редактирование сообщений. В настоящий момент реакция на редактирование с любой стороны одна: уведомлять о невозможности изменить нужное сообщение на стороне получателя. :param message: отредактированное пользователем или админом сообщение + :param l10n: объект локализации """ - await message.reply("К сожалению, редактирование сообщения не будет видно принимающей стороне. " - "Рекомендую просто отправить новое сообщение.") + await message.reply(l10n.format_value("cannot-update-edited-error")) diff --git a/bot/handlers/unsupported_reply.py b/bot/handlers/unsupported_reply.py index ce3187a..2d6df25 100644 --- a/bot/handlers/unsupported_reply.py +++ b/bot/handlers/unsupported_reply.py @@ -1,5 +1,6 @@ from aiogram import Router, F from aiogram.types import Message +from fluent.runtime import FluentLocalization from bot.config_reader import config @@ -7,11 +8,12 @@ @router.message(F.reply_to_message, F.chat.id == config.admin_chat_id, F.poll) -async def unsupported_admin_reply_types(message: Message): +async def unsupported_admin_reply_types(message: Message, l10n: FluentLocalization): """ Хэндлер на неподдерживаемые типы сообщений, т.е. те, которые не имеют смысла для копирования. Например, опросы (админ не увидит результат) :param message: сообщение от администратора + :param l10n: объект локализации """ - await message.reply("К сожалению, этот тип сообщения не поддерживается для ответа пользователю.") + await message.reply(l10n.format_value("cannot-reply-with-this-type-error")) diff --git a/bot/handlers/usermode.py b/bot/handlers/usermode.py index 10c788c..26a1436 100644 --- a/bot/handlers/usermode.py +++ b/bot/handlers/usermode.py @@ -4,6 +4,7 @@ from aiogram.filters import Command from aiogram.types import ContentType from aiogram.types import Message +from fluent.runtime import FluentLocalization from bot.blocklists import banned, shadowbanned from bot.config_reader import config @@ -12,58 +13,54 @@ router = Router() -async def _send_expiring_notification(message: Message): +async def _send_expiring_notification(message: Message, l10n: FluentLocalization): """ Отправляет "самоуничтожающееся" через 5 секунд сообщение :param message: сообщение, на которое бот отвечает подтверждением отправки + :param l10n: объект локализации """ - msg = await message.reply("Сообщение отправлено!") + msg = await message.reply(l10n.format_value("sent-confirmation")) if config.remove_sent_confirmation: await sleep(5.0) await msg.delete() @router.message(Command(commands=["start"])) -async def cmd_start(message: Message): +async def cmd_start(message: Message, l10n: FluentLocalization): """ Приветственное сообщение от бота пользователю :param message: сообщение от пользователя с командой /start + :param l10n: объект локализации """ - await message.answer( - "Привет ✌️\n" - "C моей помощью ты можешь связаться с моим хозяином и получить от него ответ. " - "Просто напиши что-нибудь в этот диалог.") + await message.answer(l10n.format_value("intro")) @router.message(Command(commands=["help"])) -async def cmd_help(message: Message): +async def cmd_help(message: Message, l10n: FluentLocalization): """ Справка для пользователя :param message: сообщение от пользователя с командой /help + :param l10n: объект локализации """ - await message.answer( - "С моей помощью ты можешь связаться с владельцем этого бота и получить от него ответ.\n" - "Просто продолжай писать в этот диалог, но учти, что поддерживаются не все типы сообщений, " - "а только текст, фото, видео, аудио, файлы и голосовые сообщения (последние лучше не использовать " - "без крайней необходимости).") + await message.answer(l10n.format_value("help")) @router.message(F.text) -async def text_message(message: Message, bot: Bot): +async def text_message(message: Message, bot: Bot, l10n: FluentLocalization): """ Хэндлер на текстовые сообщения от пользователя :param message: сообщение от пользователя для админа(-ов) + :param l10n: объект локализации """ if len(message.text) > 4000: - return await message.reply("К сожалению, длина этого сообщения превышает допустимый размер. " - "Пожалуйста, сократи свою мысль и попробуй ещё раз.") + return await message.reply(l10n.format_value("too-long-text-error")) if message.from_user.id in banned: - await message.answer("К сожалению, автор бота решил тебя заблокировать, сообщения не будут доставлены.") + await message.answer(l10n.format_value("you-were-banned-error")) elif message.from_user.id in shadowbanned: return else: @@ -71,22 +68,22 @@ async def text_message(message: Message, bot: Bot): config.admin_chat_id, message.html_text + f"\n\n#id{message.from_user.id}", parse_mode="HTML" ) - create_task(_send_expiring_notification(message)) + create_task(_send_expiring_notification(message, l10n)) @router.message(SupportedMediaFilter()) -async def supported_media(message: Message): +async def supported_media(message: Message, l10n: FluentLocalization): """ Хэндлер на медиафайлы от пользователя. Поддерживаются только типы, к которым можно добавить подпись (полный список см. в регистраторе внизу) :param message: медиафайл от пользователя + :param l10n: объект локализации """ if message.caption and len(message.caption) > 1000: - return await message.reply("К сожалению, длина подписи медиафайла превышает допустимый размер. " - "Пожалуйста, сократи свою мысль и попробуй ещё раз.") + return await message.reply(l10n.format_value("too-long-caption-error")) if message.from_user.id in banned: - await message.answer("К сожалению, автор бота решил тебя заблокировать, сообщения не будут доставлены.") + await message.answer(l10n.format_value("you-were-banned-error")) elif message.from_user.id in shadowbanned: return else: @@ -95,15 +92,16 @@ async def supported_media(message: Message): caption=((message.caption or "") + f"\n\n#id{message.from_user.id}"), parse_mode="HTML" ) - create_task(_send_expiring_notification(message)) + create_task(_send_expiring_notification(message, l10n)) @router.message() -async def unsupported_types(message: Message): +async def unsupported_types(message: Message, l10n: FluentLocalization): """ Хэндлер на неподдерживаемые типы сообщений, т.е. те, к которым нельзя добавить подпись :param message: сообщение от пользователя + :param l10n: объект локализации """ # Игнорируем служебные сообщения if message.content_type not in ( @@ -112,4 +110,4 @@ async def unsupported_types(message: Message): ContentType.MESSAGE_AUTO_DELETE_TIMER_CHANGED, ContentType.NEW_CHAT_PHOTO, ContentType.DELETE_CHAT_PHOTO, ContentType.SUCCESSFUL_PAYMENT, "proximity_alert_triggered", # в 3.0.0b3 нет поддержка этого контент-тайпа ContentType.NEW_CHAT_TITLE, ContentType.PINNED_MESSAGE): - await message.reply("К сожалению, этот тип сообщения не поддерживается. Отправь что-нибудь другое.") + await message.reply(l10n.format_value("unsupported-message-type-error")) diff --git a/bot/locales/ru/errors.ftl b/bot/locales/ru/errors.ftl new file mode 100644 index 0000000..9bff2e3 --- /dev/null +++ b/bot/locales/ru/errors.ftl @@ -0,0 +1,19 @@ +no-reply-error = Это сообщение не является ответом на какое-либо другое! + +cannot-answer-to-user-error = + Не удалось отправить сообщение адресату! + Ответ от Telegram: { $error } + +cannot-get-user-info-error = Не удалось получить информацию о пользователе! Ошибка: { $error } + +cannot-update-edited-error = К сожалению, редактирование сообщения не будет видно принимающей стороне. Рекомендую просто отправить новое сообщение. + +cannot-reply-with-this-type-error = К сожалению, этот тип сообщения не поддерживается для ответа пользователю. + +too-long-text-error = К сожалению, длина этого сообщения превышает допустимый размер. Пожалуйста, сократи свою мысль и попробуй ещё раз. + +too-long-caption-error = К сожалению, длина подписи медиафайла превышает допустимый размер. Пожалуйста, сократи свою мысль и попробуй ещё раз. + +you-were-banned-error = К сожалению, автор бота решил тебя заблокировать, сообщения не будут доставлены. + +unsupported-message-type-error = К сожалению, этот тип сообщения не поддерживается. Отправь что-нибудь другое. diff --git a/bot/locales/ru/strings.ftl b/bot/locales/ru/strings.ftl new file mode 100644 index 0000000..421f98d --- /dev/null +++ b/bot/locales/ru/strings.ftl @@ -0,0 +1,22 @@ +no = нет +user-info = + Имя: { $name } + ID: { NUMBER($id, useGrouping: 0) } + Username: { $username } +user-banned = ID { NUMBER($id, useGrouping: 0) } добавлен в список заблокированных. При попытке отправить сообщение пользователь получит уведомление о том, что заблокирован. +user-shadowbanned = ID { NUMBER($id, useGrouping: 0) } добавлен в список скрытно заблокированных. При попытке отправить сообщение пользователь не узнает, что заблокирован. +user-unbanned = ID { NUMBER($id, useGrouping: 0) } разблокирован. + +no-banned = Нет заблокированных пользователей. +list-banned-title = Список заблокированных: +list-shadowbanned-title = Список скрытно заблокированных: + +sent-confirmation = Сообщение отправлено! + +intro = + Привет ✌️ + C моей помощью ты можешь связаться с моим хозяином и получить от него ответ. Просто напиши что-нибудь в этот диалог. + +help = + С моей помощью ты можешь связаться с владельцем этого бота и получить от него ответ. + Просто продолжай писать в этот диалог, но учти, что поддерживаются не все типы сообщений, а только текст, фото, видео, аудио, файлы и голосовые сообщения (последние лучше не использовать без крайней необходимости). \ No newline at end of file diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py new file mode 100644 index 0000000..b27318c --- /dev/null +++ b/bot/middlewares/__init__.py @@ -0,0 +1,5 @@ +from .l10n import L10nMiddleware + +__all__ = [ + "L10nMiddleware" +] diff --git a/bot/middlewares/l10n.py b/bot/middlewares/l10n.py new file mode 100644 index 0000000..cbb73d9 --- /dev/null +++ b/bot/middlewares/l10n.py @@ -0,0 +1,19 @@ +from typing import Callable, Dict, Any, Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import Message +from fluent.runtime import FluentLocalization + + +class L10nMiddleware(BaseMiddleware): + def __init__(self, l10n_object: FluentLocalization): + self.l10n_object = l10n_object + + async def __call__( + self, + handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], + event: Message, + data: Dict[str, Any] + ) -> Any: + data["l10n"] = self.l10n_object + await handler(event, data) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 55b2113..d32c41b 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -4,4 +4,9 @@ services: image: groosha/telegram-feedback-bot:latest stop_signal: SIGINT restart: unless-stopped - env_file: .env + volumes: + - ".env:/app/.env" + # Если хотите переопределить локализацию, подложите нужный каталог + # - "/your/path/to/locales:/app/bot/locales" + # Или даже так: + # - "/your/custom/language:/app/bot/locales/ru" diff --git a/requirements.txt b/requirements.txt index 0a7878e..335394c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ aiogram==3.0.0b4 python-dotenv==0.20.0 pydantic==1.9.2 +fluent.runtime==0.3.1 \ No newline at end of file