Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ This directory holds implementation notes for shipped features and feature fixes

- [Button navigation cleanup](button-navigation-cleanup.md)
- [Docker Hub publish pipeline](dockerhub-publish-pipeline.md)
- [Menu label alias routing](menu-label-alias-routing.md)
- [Song artist and source URL split](song-artist-source-url-split.md)
- [Ukrainian-only bot localization](ukrainian-localization.md)
34 changes: 34 additions & 0 deletions docs/features/menu-label-alias-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Menu Label Alias Routing

## Summary

The button router now accepts both emoji-prefixed menu labels and plain-text aliases. This keeps navigation working for users who still have older keyboard labels such as `Пісні`.

## What changed

- Added menu alias resolution in `handlers.navigation.menu_text_router`.
- Mapped plain labels to existing canonical actions:
- `Головна`
- `Пісні`
- `Пошук`
- `Теги`
- `Допомога`
- `Завантажити гармонію`
- `Резервна копія`
- Reused alias resolution in search-pending guard logic so plain menu labels are not treated as search queries.
- Fixed inline callback routing regex in `build_navigation_callback_handler` so these callbacks are actually handled:
- `song:detail:<id>:<page>`
- `song:view:<id>`
- `song:archive:<id>:<page>`
- `song:archiveconfirm:<id>:<page>`
- `browser:page:<mode>:<page>`
- `browser:close`

## Testing

- Added a regression test proving `Пісні` opens the songs browser:
- `tests/test_navigation.py::test_plain_songs_label_opens_song_browser`
- Added a regression test proving callback regex matches real inline payloads:
- `tests/test_navigation.py::test_navigation_callback_handler_matches_song_and_browser_callbacks`
- Verified navigation and handler suites:
- `uv run pytest tests/test_navigation.py tests/test_handlers.py`
36 changes: 27 additions & 9 deletions src/handlers/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@
BROWSER_PAGE_SIZE = 8
SEARCH_PENDING_KEY = "search_pending"
SONG_BROWSER_STATE_KEY = "song_browser_state"
MENU_TEXT_ALIASES = {
MENU_START: {MENU_START, "Головна"},
MENU_SONGS: {MENU_SONGS, "Пісні"},
MENU_SEARCH: {MENU_SEARCH, "Пошук"},
MENU_TAGS: {MENU_TAGS, "Теги"},
MENU_HELP: {MENU_HELP, "Допомога"},
MENU_UPLOAD_CHART: {MENU_UPLOAD_CHART, "Завантажити гармонію"},
MENU_BACKUP: {MENU_BACKUP, "Резервна копія"},
}


class BrowserItem(TypedDict):
Expand All @@ -58,39 +67,40 @@ async def menu_text_router(update: Update, context: ContextTypes.DEFAULT_TYPE) -
if message is None or message.text is None:
return
text = message.text.strip()
menu_action = _resolve_menu_action(text)
state = user_state(context)

if text == MENU_START:
if menu_action == MENU_START:
_reset_navigation_state(context)
await send_home_screen(update, context)
return
if text == MENU_SONGS:
if menu_action == MENU_SONGS:
_clear_search_pending(context)
await show_song_browser(update, context)
return
if text == MENU_SEARCH:
if menu_action == MENU_SEARCH:
state[SEARCH_PENDING_KEY] = True
await message.reply_text(
"Надішліть текст для пошуку або натисніть «Скасувати».",
reply_markup=cancel_markup(update),
)
return
if text == MENU_TAGS:
if menu_action == MENU_TAGS:
_clear_search_pending(context)
await tags_command(update, context)
return
if text == MENU_HELP:
if menu_action == MENU_HELP:
_clear_search_pending(context)
await help_command(update, context)
return
if text == MENU_UPLOAD_CHART:
if menu_action == MENU_UPLOAD_CHART:
_clear_search_pending(context)
if not is_admin_user(update, context):
await message.reply_text("Для цієї дії потрібні права адміністратора.")
return
await show_upload_target_picker(update, context)
return
if text == MENU_BACKUP:
if menu_action == MENU_BACKUP:
_clear_search_pending(context)
if not is_admin_user(update, context):
await message.reply_text("Для цієї дії потрібні права адміністратора.")
Expand All @@ -103,7 +113,7 @@ async def menu_text_router(update: Update, context: ContextTypes.DEFAULT_TYPE) -
_reset_navigation_state(context)
await send_home_screen(update, context, prefix="Скасовано.")
return
if text in MAIN_MENU_BUTTONS:
if menu_action in MAIN_MENU_BUTTONS:
_clear_search_pending(context)
await message.reply_text("Для навігації використовуйте кнопки меню.")
return
Expand All @@ -117,6 +127,13 @@ async def menu_text_router(update: Update, context: ContextTypes.DEFAULT_TYPE) -
)


def _resolve_menu_action(text: str) -> str | None:
for action, aliases in MENU_TEXT_ALIASES.items():
if text in aliases:
return action
return None


async def navigation_callback_router(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if query is None or not isinstance(query.data, str):
Expand Down Expand Up @@ -205,7 +222,8 @@ def build_navigation_callback_handler() -> CallbackQueryHandler:
return CallbackQueryHandler(
navigation_callback_router,
pattern=(
r"^(browser:|song:(detail|view|archive|archiveconfirm):|"
r"^(browser:(page:[bu]:\d+|close)|"
r"song:(detail:\d+:\d+|view:\d+|archive:\d+:\d+|archiveconfirm:\d+:\d+)|"
r"backup:(menu|export|close)|nav:home)$"
),
)
Expand Down
43 changes: 41 additions & 2 deletions tests/test_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@

from bot.runtime import SETTINGS_KEY, SONG_SERVICE_KEY
from config.settings import Settings
from handlers.navigation import SEARCH_PENDING_KEY, SONG_BROWSER_STATE_KEY, menu_text_router
from handlers.navigation import navigation_callback_router as navigation_callback_router
from handlers.navigation import (
SEARCH_PENDING_KEY,
SONG_BROWSER_STATE_KEY,
build_navigation_callback_handler,
menu_text_router,
)
from handlers.navigation import (
navigation_callback_router as navigation_callback_router,
)
from handlers.ui import MENU_SEARCH, MENU_SONGS, MENU_START
from models.song import Song, SongStatus

Expand Down Expand Up @@ -108,6 +115,22 @@ async def test_menu_songs_button_opens_song_browser() -> None:
assert keyboard.inline_keyboard[0][0].callback_data == "song:detail:5:0"


@pytest.mark.asyncio
async def test_plain_songs_label_opens_song_browser() -> None:
song_service = SimpleNamespace(list_songs=AsyncMock(return_value=[build_song()]))
context = build_context(song_service=song_service)
update, reply = build_message_update(text="Пісні")

await menu_text_router(update, context)

assert SONG_BROWSER_STATE_KEY in context.user_data
reply.assert_awaited_once()
message_text = reply.await_args.args[0]
assert "Активні пісні (1)" in message_text
keyboard = reply.await_args.kwargs["reply_markup"]
assert keyboard.inline_keyboard[0][0].callback_data == "song:detail:5:0"


@pytest.mark.asyncio
async def test_menu_search_prompt_then_query_opens_browser_results() -> None:
song = build_song()
Expand Down Expand Up @@ -269,6 +292,22 @@ async def test_stale_upload_page_callback_recovers_state_and_renders_page() -> N
assert keyboard.inline_keyboard[0][0].callback_data == "upload:start:5"


def test_navigation_callback_handler_matches_song_and_browser_callbacks() -> None:
handler = build_navigation_callback_handler()

assert handler.pattern.match("song:detail:5:0")
assert handler.pattern.match("song:view:5")
assert handler.pattern.match("song:archive:5:0")
assert handler.pattern.match("song:archiveconfirm:5:0")
assert handler.pattern.match("browser:page:b:0")
assert handler.pattern.match("browser:close")
assert handler.pattern.match("backup:menu")
assert handler.pattern.match("backup:export")
assert handler.pattern.match("backup:close")
assert handler.pattern.match("nav:home")
assert not handler.pattern.match("backup:import:start")


@pytest.mark.asyncio
async def test_archive_confirmation_success_sends_next_actions() -> None:
archived_song = build_song()
Expand Down
Loading