Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions reverse_image_search_bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,51 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE):
BotCommand("start", "Start the bot"),
]

_LOCALISED_COMMANDS: dict[str, list[BotCommand]] = {
"ru": [
BotCommand("search", "Поиск источника изображения (ответьте на изображение)"),
BotCommand("settings", "Настройки бота для этого чата"),
BotCommand("help", "Показать справку"),
BotCommand("start", "Запустить бота"),
],
"zh": [
BotCommand("search", "搜索图片来源(回复图片)"),
BotCommand("settings", "配置此聊天的机器人设置"),
BotCommand("help", "显示帮助"),
BotCommand("start", "启动机器人"),
],
"es": [
BotCommand("search", "Buscar origen de imagen (responder a imagen)"),
BotCommand("settings", "Configurar el bot para este chat"),
BotCommand("help", "Mostrar ayuda"),
BotCommand("start", "Iniciar el bot"),
],
"it": [
BotCommand("search", "Cerca l'origine dell'immagine (rispondi a un'immagine)"),
BotCommand("settings", "Configura il bot per questa chat"),
BotCommand("help", "Mostra aiuto"),
BotCommand("start", "Avvia il bot"),
],
"ar": [
BotCommand("search", "البحث عن مصدر الصورة (رد على صورة)"),
BotCommand("settings", "إعدادات البوت لهذه المحادثة"),
BotCommand("help", "عرض المساعدة"),
BotCommand("start", "تشغيل البوت"),
],
"ja": [
BotCommand("search", "画像のソースを検索(画像に返信)"),
BotCommand("settings", "このチャットのボット設定"),
BotCommand("help", "ヘルプを表示"),
BotCommand("start", "ボットを起動"),
],
"de": [
BotCommand("search", "Bildquelle suchen (auf Bild antworten)"),
BotCommand("settings", "Bot-Einstellungen für diesen Chat"),
BotCommand("help", "Hilfe anzeigen"),
BotCommand("start", "Bot starten"),
],
}

_ADMIN_COMMANDS = [
*_PUBLIC_COMMANDS,
BotCommand("ban", "Ban/unban a user by ID"),
Expand All @@ -141,10 +186,16 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE):
async def _set_bot_commands(app: Application) -> None:
"""Register bot command menus with Telegram.

Public commands are set for the default scope. Admin commands (including
/ban and /id) are set per admin private chat via BotCommandScopeChat.
Public commands are set for the default scope with localised variants.
Admin commands (including /ban and /id) are set per admin private chat
via BotCommandScopeChat (English only).
"""
await app.bot.set_my_commands(_PUBLIC_COMMANDS, scope=BotCommandScopeDefault())
for lang, commands in _LOCALISED_COMMANDS.items():
try:
await app.bot.set_my_commands(commands, scope=BotCommandScopeDefault(), language_code=lang)
except Exception:
logger.warning("Failed to set %s commands", lang)
for admin_id in settings.ADMIN_IDS:
try:
await app.bot.set_my_commands(_ADMIN_COMMANDS, scope=BotCommandScopeChat(chat_id=admin_id))
Expand Down
31 changes: 28 additions & 3 deletions tests/test_bot_commands_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from reverse_image_search_bot.bot import (
_ADMIN_COMMANDS,
_LOCALISED_COMMANDS,
_PUBLIC_COMMANDS,
_set_bot_commands,
)
Expand Down Expand Up @@ -51,20 +52,44 @@ async def test_sets_default_scope(self, mock_settings, mock_app):
await _set_bot_commands(mock_app)
mock_app.bot.set_my_commands.assert_any_call(_PUBLIC_COMMANDS, scope=BotCommandScopeDefault())

@pytest.mark.asyncio
@patch("reverse_image_search_bot.bot.settings")
async def test_sets_localised_commands(self, mock_settings, mock_app):
mock_settings.ADMIN_IDS = []
await _set_bot_commands(mock_app)
for lang, commands in _LOCALISED_COMMANDS.items():
mock_app.bot.set_my_commands.assert_any_call(
commands, scope=BotCommandScopeDefault(), language_code=lang
)
# 1 default + N localised + 0 admin
assert mock_app.bot.set_my_commands.call_count == 1 + len(_LOCALISED_COMMANDS)

@pytest.mark.asyncio
@patch("reverse_image_search_bot.bot.settings")
async def test_sets_admin_scope_per_admin(self, mock_settings, mock_app):
mock_settings.ADMIN_IDS = [111, 222]
await _set_bot_commands(mock_app)
mock_app.bot.set_my_commands.assert_any_call(_ADMIN_COMMANDS, scope=BotCommandScopeChat(chat_id=111))
mock_app.bot.set_my_commands.assert_any_call(_ADMIN_COMMANDS, scope=BotCommandScopeChat(chat_id=222))
# 1 default + 2 admin = 3 calls
assert mock_app.bot.set_my_commands.call_count == 3
# 1 default + N localised + 2 admin
assert mock_app.bot.set_my_commands.call_count == 1 + len(_LOCALISED_COMMANDS) + 2

@pytest.mark.asyncio
@patch("reverse_image_search_bot.bot.settings")
async def test_admin_failure_does_not_crash(self, mock_settings, mock_app):
mock_settings.ADMIN_IDS = [111]
mock_app.bot.set_my_commands = AsyncMock(side_effect=[None, Exception("chat not found")])
# 1 default + N localised succeed, then admin fails
side_effects: list[bool | Exception] = [True] * (1 + len(_LOCALISED_COMMANDS)) + [Exception("chat not found")]
mock_app.bot.set_my_commands = AsyncMock(side_effect=side_effects)
# Should not raise
await _set_bot_commands(mock_app)

@pytest.mark.asyncio
@patch("reverse_image_search_bot.bot.settings")
async def test_localised_failure_does_not_crash(self, mock_settings, mock_app):
mock_settings.ADMIN_IDS = []
# Default succeeds, first localised fails, rest succeed
side_effects: list[bool | Exception] = [True, Exception("lang fail")] + [True] * (len(_LOCALISED_COMMANDS) - 1)
mock_app.bot.set_my_commands = AsyncMock(side_effect=side_effects)
# Should not raise
await _set_bot_commands(mock_app)