В меню Telegram появились команды с русскими подписями

Telegram не принимает кириллицу в именах команд, поэтому в меню теперь латинские алиасы (/help, /info, /nick, /list, /who, /purify, /eggs, /shop, /top, /sub, /unsub) с русскими описаниями, а русские команды продолжают работать при вводе вручную. При каждом старте бот сверяет набор с ожидаемым и обновляет его, если меню меняли вручную или оно пустое.
This commit is contained in:
MpakobecEx 2026-10-25 20:20:17 +05:00
parent 0ae337711e
commit 64cf24d949
9 changed files with 142 additions and 14 deletions

View File

@ -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_*`);

View File

@ -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:

59
bot/commands_menu.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.5.1"
__version__ = "0.5.2"

View File

@ -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]

View File

@ -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"