From 1c85590f6ff0bbe56e01b00dd931f7f2312c3c05 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sun, 25 Oct 2026 20:20:17 +0500 Subject: [PATCH] =?UTF-8?q?=D0=92=20=D0=BC=D0=B5=D0=BD=D1=8E=20Telegram=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=8F=D0=B2=D0=B8=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20=D1=81=20=D1=80=D1=83?= =?UTF-8?q?=D1=81=D1=81=D0=BA=D0=B8=D0=BC=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram не принимает кириллицу в именах команд, поэтому в меню теперь латинские алиасы (/help, /info, /nick, /list, /who, /purify, /eggs, /shop, /top, /sub, /unsub) с русскими описаниями, а русские команды продолжают работать при вводе вручную. При каждом старте бот сверяет набор с ожидаемым и обновляет его, если меню меняли вручную или оно пустое. --- README.md | 7 ++++- bot/__main__.py | 4 +++ bot/commands_menu.py | 59 ++++++++++++++++++++++++++++++++++++ bot/handlers/commands.py | 16 +++++----- bot/handlers/economy.py | 4 +-- bot/handlers/shop.py | 2 +- common/version.py | 2 +- tests/test_commands_menu.py | 60 +++++++++++++++++++++++++++++++++++++ tests/test_version.py | 2 +- 9 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 bot/commands_menu.py create mode 100644 tests/test_commands_menu.py diff --git a/README.md b/README.md index ed9a439..e1d1c96 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.1`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.2`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. @@ -207,6 +207,11 @@ git push origin master --tags `/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`, `/магазин`, `/рейтинг`, `/отписка`, `/подписка`. +В меню Telegram показываются латинские алиасы (`/help`, `/info`, `/nick`, `/list`, +`/who`, `/purify`, `/eggs`, `/shop`, `/top`, `/sub`, `/unsub`) с русскими описаниями: +Telegram не принимает кириллицу в именах команд. Русские команды работают при вводе +вручную. Набор сверяется с ожидаемым при каждом старте бота (`ensure_commands`). + ## Переменные окружения - общие — `.env.example` (включая `CLICKHOUSE_*` и параметры батчинга `ANALYTICS_*`); diff --git a/bot/__main__.py b/bot/__main__.py index 4659a2f..95bdd73 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -6,6 +6,7 @@ import asyncio import logging from bot.bot_factory import create_bot, create_dispatcher +from bot.commands_menu import ensure_commands from bot.services.heartbeat import run_heartbeat from bot.services.notifications import run_notifications from bot.services.runtime import uptime_seconds @@ -30,6 +31,9 @@ async def main() -> None: bot = create_bot() dp = create_dispatcher() + # Сверяем меню команд при каждом старте (Telegram разрешает только ASCII-имена). + await ensure_commands(bot) + notifications_task = asyncio.create_task(run_notifications(bot)) heartbeat_task = asyncio.create_task(run_heartbeat(bot)) try: diff --git a/bot/commands_menu.py b/bot/commands_menu.py new file mode 100644 index 0000000..8bdcc0d --- /dev/null +++ b/bot/commands_menu.py @@ -0,0 +1,59 @@ +"""Меню команд бота: латинские имена + русские описания. + +Telegram разрешает в меню только ``a-z``, ``0-9`` и ``_``, поэтому русские +команды остаются доступны при вводе вручную, а в меню показываются алиасы. +""" + +from __future__ import annotations + +import logging + +from aiogram import Bot +from aiogram.types import BotCommand + +logger = logging.getLogger(__name__) + +#: Ожидаемый набор команд в меню (порядок = порядок в Telegram). +BOT_COMMANDS: tuple[BotCommand, ...] = ( + BotCommand(command="help", description="Помощь"), + BotCommand(command="info", description="Инфо"), + BotCommand(command="nick", description="Сменить ник"), + BotCommand(command="list", description="Список игроков"), + BotCommand(command="who", description="Кто это"), + BotCommand(command="purify", description="Очищение"), + BotCommand(command="eggs", description="Яички"), + BotCommand(command="shop", description="Магазин"), + BotCommand(command="top", description="Рейтинг"), + BotCommand(command="sub", description="Подписка"), + BotCommand(command="unsub", description="Отписка"), +) + + +def _key(commands: list[BotCommand]) -> list[tuple[str, str]]: + return [(command.command, command.description) for command in commands] + + +async def ensure_commands(bot: Bot) -> bool: + """Сверить меню команд с ожидаемым и обновить при расхождении. + + Возвращает ``True``, если набор пришлось обновить. Ошибки Telegram не + прерывают запуск бота — только пишутся в лог. + """ + try: + current = await bot.get_my_commands() + except Exception: + logger.warning("Не удалось прочитать меню команд Telegram", exc_info=True) + return False + + if _key(current) == _key(list(BOT_COMMANDS)): + logger.info("Меню команд актуально (%s команд)", len(BOT_COMMANDS)) + return False + + try: + await bot.set_my_commands(list(BOT_COMMANDS)) + except Exception: + logger.warning("Не удалось обновить меню команд Telegram", exc_info=True) + return False + + logger.info("Меню команд обновлено (%s команд)", len(BOT_COMMANDS)) + return True diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py index 81436a6..7c0fd95 100644 --- a/bot/handlers/commands.py +++ b/bot/handlers/commands.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) router = Router(name="commands") -@router.message(Command("ник", ignore_case=True)) +@router.message(Command("ник", "nick", ignore_case=True)) async def set_nick_handler(message: Message) -> None: if not await access.check_user(message): return @@ -41,7 +41,7 @@ async def set_nick_handler(message: Message) -> None: await message.reply(system.MSG_NICK_ERROR) -@router.message(Command("список", ignore_case=True)) +@router.message(Command("список", "list", ignore_case=True)) async def list_nicks_handler(message: Message) -> None: if not await access.check_user(message): return @@ -66,7 +66,7 @@ async def list_nicks_handler(message: Message) -> None: await message.answer(render(await get_text("economy.nick_list"), context)) -@router.message(Command("кто", ignore_case=True)) +@router.message(Command("кто", "who", ignore_case=True)) async def get_nick_handler(message: Message) -> None: if not await access.check_user(message): return @@ -81,14 +81,14 @@ async def get_nick_handler(message: Message) -> None: await message.reply(user_info.nick) -@router.message(Command("помощь", ignore_case=True)) +@router.message(Command("помощь", "help", ignore_case=True)) async def help_handler(message: Message) -> None: if not await access.check_user(message): return await message.answer(await get_text("info.help")) -@router.message(Command("инфо", ignore_case=True)) +@router.message(Command("инфо", "info", ignore_case=True)) async def info_handler(message: Message) -> None: if not await access.check_user(message): return @@ -116,7 +116,7 @@ async def info_handler(message: Message) -> None: await message.answer(render(await get_text("info.profile"), context)) -@router.message(Command("рейтинг", ignore_case=True)) +@router.message(Command("рейтинг", "top", ignore_case=True)) async def top_handler(message: Message) -> None: if not await access.check_user(message): return @@ -125,7 +125,7 @@ async def top_handler(message: Message) -> None: await message.answer(render(await get_text("economy.top"), context)) -@router.message(Command("отписка", ignore_case=True)) +@router.message(Command("отписка", "unsub", ignore_case=True)) async def unsubscribe_handler(message: Message) -> None: if not await access.check_user(message): return @@ -133,7 +133,7 @@ async def unsubscribe_handler(message: Message) -> None: await message.reply(system.MSG_UNSUBSCRIBED) -@router.message(Command("подписка", ignore_case=True)) +@router.message(Command("подписка", "sub", ignore_case=True)) async def subscribe_handler(message: Message) -> None: if not await access.check_user(message): return diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py index 75d64c4..9304c06 100644 --- a/bot/handlers/economy.py +++ b/bot/handlers/economy.py @@ -23,7 +23,7 @@ router = Router(name="economy") COIN_CHANCE = 27 # из 100 -@router.message(Command("очищение", ignore_case=True)) +@router.message(Command("очищение", "purify", ignore_case=True)) async def purification_handler(message: Message) -> None: if not await access.check_user(message): return @@ -43,7 +43,7 @@ async def purification_handler(message: Message) -> None: ) -@router.message(Command("яички", ignore_case=True)) +@router.message(Command("яички", "eggs", ignore_case=True)) async def eggs_handler(message: Message) -> None: settings = get_settings() if not is_egg_event_active(settings): diff --git a/bot/handlers/shop.py b/bot/handlers/shop.py index b7126be..088e752 100644 --- a/bot/handlers/shop.py +++ b/bot/handlers/shop.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) router = Router(name="shop") -@router.message(Command("магазин", ignore_case=True)) +@router.message(Command("магазин", "shop", ignore_case=True)) async def shop_handler(message: Message) -> None: if not await access.check_user(message): return diff --git a/common/version.py b/common/version.py index 32eafd1..24a1ca2 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.5.1" +__version__ = "0.5.2" diff --git a/tests/test_commands_menu.py b/tests/test_commands_menu.py new file mode 100644 index 0000000..b6fc037 --- /dev/null +++ b/tests/test_commands_menu.py @@ -0,0 +1,60 @@ +"""Тесты меню команд: допустимые имена и синхронизация при старте.""" + +from __future__ import annotations + +import re + +from bot.commands_menu import BOT_COMMANDS, ensure_commands + + +class FakeBot: + def __init__(self, current: list) -> None: + self.current = current + self.set_calls: list[list] = [] + + async def get_my_commands(self) -> list: + return self.current + + async def set_my_commands(self, commands: list) -> None: + self.set_calls.append(commands) + + +class BrokenBot: + async def get_my_commands(self) -> list: + raise RuntimeError("telegram unavailable") + + +def test_command_names_are_telegram_compatible() -> None: + names = [command.command for command in BOT_COMMANDS] + + assert len(names) == len(set(names)) + for name in names: + assert re.fullmatch(r"[a-z0-9_]{1,32}", name), name + for command in BOT_COMMANDS: + assert command.description + + +async def test_menu_is_set_when_empty() -> None: + bot = FakeBot([]) + + assert await ensure_commands(bot) is True + assert len(bot.set_calls) == 1 + assert [command.command for command in bot.set_calls[0]] == [command.command for command in BOT_COMMANDS] + + +async def test_menu_is_not_touched_when_actual() -> None: + bot = FakeBot(list(BOT_COMMANDS)) + + assert await ensure_commands(bot) is False + assert bot.set_calls == [] + + +async def test_menu_updates_when_different() -> None: + bot = FakeBot([BOT_COMMANDS[0]]) + + assert await ensure_commands(bot) is True + assert len(bot.set_calls[0]) == len(BOT_COMMANDS) + + +async def test_telegram_error_does_not_crash_startup() -> None: + assert await ensure_commands(BrokenBot()) is False # type: ignore[arg-type] diff --git a/tests/test_version.py b/tests/test_version.py index 37244eb..7cd6b04 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.5.1" + assert __version__ == "0.5.2"