В меню Telegram появились команды с русскими подписями
Telegram не принимает кириллицу в именах команд, поэтому в меню теперь латинские алиасы (/help, /info, /nick, /list, /who, /purify, /eggs, /shop, /top, /sub, /unsub) с русскими описаниями, а русские команды продолжают работать при вводе вручную. При каждом старте бот сверяет набор с ожидаемым и обновляет его, если меню меняли вручную или оно пустое.
This commit is contained in:
parent
0ae337711e
commit
64cf24d949
|
|
@ -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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `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_*`);
|
- общие — `.env.example` (включая `CLICKHOUSE_*` и параметры батчинга `ANALYTICS_*`);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from bot.bot_factory import create_bot, create_dispatcher
|
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.heartbeat import run_heartbeat
|
||||||
from bot.services.notifications import run_notifications
|
from bot.services.notifications import run_notifications
|
||||||
from bot.services.runtime import uptime_seconds
|
from bot.services.runtime import uptime_seconds
|
||||||
|
|
@ -30,6 +31,9 @@ async def main() -> None:
|
||||||
bot = create_bot()
|
bot = create_bot()
|
||||||
dp = create_dispatcher()
|
dp = create_dispatcher()
|
||||||
|
|
||||||
|
# Сверяем меню команд при каждом старте (Telegram разрешает только ASCII-имена).
|
||||||
|
await ensure_commands(bot)
|
||||||
|
|
||||||
notifications_task = asyncio.create_task(run_notifications(bot))
|
notifications_task = asyncio.create_task(run_notifications(bot))
|
||||||
heartbeat_task = asyncio.create_task(run_heartbeat(bot))
|
heartbeat_task = asyncio.create_task(run_heartbeat(bot))
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||||
router = Router(name="commands")
|
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:
|
async def set_nick_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
@ -41,7 +41,7 @@ async def set_nick_handler(message: Message) -> None:
|
||||||
await message.reply(system.MSG_NICK_ERROR)
|
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:
|
async def list_nicks_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
@ -66,7 +66,7 @@ async def list_nicks_handler(message: Message) -> None:
|
||||||
await message.answer(render(await get_text("economy.nick_list"), context))
|
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:
|
async def get_nick_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
@ -81,14 +81,14 @@ async def get_nick_handler(message: Message) -> None:
|
||||||
await message.reply(user_info.nick)
|
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:
|
async def help_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
await message.answer(await get_text("info.help"))
|
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:
|
async def info_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
@ -116,7 +116,7 @@ async def info_handler(message: Message) -> None:
|
||||||
await message.answer(render(await get_text("info.profile"), context))
|
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:
|
async def top_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
@ -125,7 +125,7 @@ async def top_handler(message: Message) -> None:
|
||||||
await message.answer(render(await get_text("economy.top"), context))
|
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:
|
async def unsubscribe_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
@ -133,7 +133,7 @@ async def unsubscribe_handler(message: Message) -> None:
|
||||||
await message.reply(system.MSG_UNSUBSCRIBED)
|
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:
|
async def subscribe_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ router = Router(name="economy")
|
||||||
COIN_CHANCE = 27 # из 100
|
COIN_CHANCE = 27 # из 100
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("очищение", ignore_case=True))
|
@router.message(Command("очищение", "purify", ignore_case=True))
|
||||||
async def purification_handler(message: Message) -> None:
|
async def purification_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
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:
|
async def eggs_handler(message: Message) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not is_egg_event_active(settings):
|
if not is_egg_event_active(settings):
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||||
router = Router(name="shop")
|
router = Router(name="shop")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("магазин", ignore_case=True))
|
@router.message(Command("магазин", "shop", ignore_case=True))
|
||||||
async def shop_handler(message: Message) -> None:
|
async def shop_handler(message: Message) -> None:
|
||||||
if not await access.check_user(message):
|
if not await access.check_user(message):
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.5.1"
|
__version__ = "0.5.2"
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.5.1"
|
assert __version__ == "0.5.2"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue