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
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Suppress song source link previews

Status: needs-triage
Status: done

## What to build

Suppress Telegram link previews everywhere the bot renders song details or song source URLs, while keeping the original song source URL visible in the message text.

## Acceptance criteria

- [ ] Song detail messages that include a source URL keep the source URL as visible text and do not generate Telegram web previews.
- [ ] Browse, search, creation confirmation, and update confirmation messages that render song source URLs suppress Telegram web previews consistently.
- [ ] The implementation does not change song source URL storage, validation, or chart source URL behavior.
- [ ] Handler tests cover link preview suppression on song-rendering messages that include source URLs.
- [x] Song detail messages that include a source URL keep the source URL as visible text and do not generate Telegram web previews.
- [x] Browse, search, creation confirmation, and update confirmation messages that render song source URLs suppress Telegram web previews consistently.
- [x] The implementation does not change song source URL storage, validation, or chart source URL behavior.
- [x] Handler tests cover link preview suppression on song-rendering messages that include source URLs.

## Blocked by

Expand Down
8 changes: 6 additions & 2 deletions src/handlers/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
EMPTY_SEARCH_RESULTS_MESSAGE,
NEXT_ACTIONS_MESSAGE,
)
from handlers.repertoire import format_song, tags_command
from handlers.repertoire import format_song, song_link_preview_options, tags_command
from handlers.ui import (
BUTTON_CANCEL,
MAIN_MENU_BUTTONS,
Expand Down Expand Up @@ -437,7 +437,11 @@ async def _render_song_detail(
page=page,
is_admin=is_admin_user(update, context),
)
await query.edit_message_text("Деталі пісні:\n" + format_song(song), reply_markup=keyboard)
await query.edit_message_text(
"Деталі пісні:\n" + format_song(song),
reply_markup=keyboard,
link_preview_options=song_link_preview_options(song),
)


def _song_detail_keyboard(*, song_id: int, page: int, is_admin: bool) -> InlineKeyboardMarkup:
Expand Down
17 changes: 15 additions & 2 deletions src/handlers/repertoire.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dataclasses import dataclass
from typing import Any, cast

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, LinkPreviewOptions, Update
from telegram.ext import (
BaseHandler,
CallbackQueryHandler,
Expand Down Expand Up @@ -66,6 +66,7 @@
PENDING_SONG_KEY = "pending_song"
EDIT_SONG_ID_KEY = "edit_song_id"
EDIT_FIELD_KEY = "edit_field"
SUPPRESS_LINK_PREVIEWS = LinkPreviewOptions(is_disabled=True)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -102,6 +103,12 @@ def format_compact_song(song: Song) -> str:
return f"#{song.id} {song.title} | {song.artist} | Тональність: {song.key}"


def song_link_preview_options(*songs: Song) -> LinkPreviewOptions | None:
if any(song.source_url for song in songs):
return SUPPRESS_LINK_PREVIEWS
return None


def _status_label(status: SongStatus) -> str:
labels = {
SongStatus.ACTIVE: "активна",
Expand Down Expand Up @@ -204,7 +211,10 @@ async def _reply_song_results(

detailed_body = "\n\n".join(format_song(song) for song in songs)
if len(detailed_body) <= RESULT_MESSAGE_CHAR_LIMIT:
await message.reply_text(detailed_body)
await message.reply_text(
detailed_body,
link_preview_options=song_link_preview_options(*songs),
)
return

compact_lines = [format_compact_song(song) for song in songs]
Expand Down Expand Up @@ -714,6 +724,7 @@ async def add_song_notes(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await update.effective_message.reply_text(
"Пісню створено:\n" + format_song(song),
reply_markup=home_or_remove_markup(update, context),
link_preview_options=song_link_preview_options(song),
)
return ConversationHandler.END

Expand Down Expand Up @@ -770,6 +781,7 @@ async def _start_edit_song(
+ "\n\nЯке поле змінити? Натисніть кнопку нижче."
+ "\nНатисніть «Скасувати», щоб зупинити.",
reply_markup=_edit_field_keyboard(song_id),
link_preview_options=song_link_preview_options(song),
)
return EDIT_FIELD

Expand Down Expand Up @@ -888,6 +900,7 @@ async def edit_song_value(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await update.effective_message.reply_text(
"Пісню оновлено:\n" + format_song(song),
reply_markup=home_or_remove_markup(update, context),
link_preview_options=song_link_preview_options(song),
)
await update.effective_message.reply_text(
NEXT_ACTIONS_MESSAGE,
Expand Down
91 changes: 91 additions & 0 deletions tests/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ def build_telegram_update(*, edited: bool, text: str = "value") -> Update:
return Update(update_id=1, message=message)


def assert_link_previews_disabled(call: object) -> None:
assert call.kwargs["link_preview_options"].is_disabled is True


@pytest.mark.asyncio
async def test_ensure_admin_rejects_non_admin() -> None:
update, reply = build_update(user_id=999)
Expand Down Expand Up @@ -299,6 +303,20 @@ async def test_list_songs_command_sends_detailed_song_cards_when_result_fits() -
assert not message.startswith("Активні пісні (")


@pytest.mark.asyncio
async def test_list_songs_command_suppresses_previews_for_visible_source_urls() -> None:
update, reply = build_update()
song = build_song(source_url="https://example.org/amazing-grace")
song_service = SimpleNamespace(list_songs=AsyncMock(return_value=[song]))
context = build_context(song_service=song_service)

await list_songs_command(update, context)

reply.assert_awaited_once()
assert "Джерело (оригінал): https://example.org/amazing-grace" in reply.await_args.args[0]
assert_link_previews_disabled(reply.await_args)


@pytest.mark.asyncio
async def test_search_command_sends_detailed_song_cards_when_result_fits() -> None:
update, reply = build_update()
Expand All @@ -318,6 +336,20 @@ async def test_search_command_sends_detailed_song_cards_when_result_fits() -> No
assert not message.startswith('Результати для "grace" (')


@pytest.mark.asyncio
async def test_search_command_suppresses_previews_for_visible_source_urls() -> None:
update, reply = build_update()
song = build_song(source_url="https://example.org/amazing-grace")
song_service = SimpleNamespace(search_songs=AsyncMock(return_value=[song]))
context = build_context(args=["grace"], song_service=song_service)

await search_songs_command(update, context)

reply.assert_awaited_once()
assert "Джерело (оригінал): https://example.org/amazing-grace" in reply.await_args.args[0]
assert_link_previews_disabled(reply.await_args)


@pytest.mark.asyncio
async def test_list_songs_command_falls_back_to_compact_summary_when_output_is_long() -> None:
update, reply = build_update()
Expand Down Expand Up @@ -565,6 +597,20 @@ async def test_edit_song_start_shows_editable_field_previews() -> None:
assert callback_data[-1] == "edit:cancel"


@pytest.mark.asyncio
async def test_edit_song_start_suppresses_previews_for_visible_source_url() -> None:
update, reply = build_update()
song = build_song(source_url="https://example.org/source")
song_service = SimpleNamespace(get_song=AsyncMock(return_value=song))
context = build_context(args=["5"], song_service=song_service)

state = await edit_song_start(update, context)

assert state == EDIT_FIELD
assert "Джерело (оригінал): https://example.org/source" in reply.await_args.args[0]
assert_link_previews_disabled(reply.await_args)


@pytest.mark.asyncio
async def test_add_song_artist_prompts_for_source_with_original_link_text() -> None:
update, reply = build_update()
Expand Down Expand Up @@ -617,6 +663,31 @@ async def test_add_song_notes_creates_song_without_arrangement_notes_step() -> N
assert "Пісню створено:" in reply.await_args.args[0]


@pytest.mark.asyncio
async def test_add_song_notes_suppresses_previews_for_visible_source_url() -> None:
update, reply = build_update()
update.effective_message.text = "Пропустити"
created_song = build_song(source_url="https://example.org/source")
song_service = SimpleNamespace(create_song=AsyncMock(return_value=created_song))
context = build_context(song_service=song_service)
context.user_data["pending_song"] = {
"title": "Amazing Grace",
"artist": "Traditional",
"source_url": "https://example.org/source",
"key": "G",
"capo": 1,
"time_signature": "3/4",
"tempo_bpm": 72,
"tags": ["hymn", "classic"],
}

state = await add_song_notes(update, context)

assert state == -1
assert "Джерело (оригінал): https://example.org/source" in reply.await_args.args[0]
assert_link_previews_disabled(reply.await_args)


@pytest.mark.asyncio
async def test_edit_song_field_shows_prompt_with_current_value() -> None:
update, query, reply = build_callback_update(data="edit:field:5:tempo")
Expand Down Expand Up @@ -805,6 +876,26 @@ async def test_edit_song_value_updates_song_and_clears_state() -> None:
assert callbacks == ["song:detail:5:2", "browser:page:b:2", "nav:home"]


@pytest.mark.asyncio
async def test_edit_song_value_suppresses_previews_for_visible_source_url() -> None:
update, reply = build_update()
updated_song = build_song(source_url="https://example.org/source")
song_service = SimpleNamespace(
update_song=AsyncMock(return_value=updated_song),
)
context = build_context(song_service=song_service)
context.user_data[EDIT_SONG_ID_KEY] = updated_song.id
context.user_data[EDIT_FIELD_KEY] = "title"
update.effective_message.text = "Amazing Grace"

state = await edit_song_value(update, context)

assert state == -1
success_call = reply.await_args_list[0]
assert "Джерело (оригінал): https://example.org/source" in success_call.args[0]
assert_link_previews_disabled(success_call)


@pytest.mark.asyncio
async def test_export_backup_command_requires_admin() -> None:
update, reply = build_update(user_id=2)
Expand Down
23 changes: 23 additions & 0 deletions tests/test_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ def build_song(
song_id: int = 5,
title: str = "Amazing Grace",
artist: str = "Traditional",
source_url: str | None = None,
) -> Song:
song = Song(
title=title,
artist=artist,
source_url=source_url,
key="G",
status=SongStatus.ACTIVE,
)
Expand Down Expand Up @@ -99,6 +101,10 @@ def build_callback_update(
return update, query


def assert_link_previews_disabled(call: object) -> None:
assert call.kwargs["link_preview_options"].is_disabled is True


@pytest.mark.asyncio
async def test_menu_songs_button_opens_song_browser() -> None:
song_service = SimpleNamespace(list_songs=AsyncMock(return_value=[build_song()]))
Expand Down Expand Up @@ -310,6 +316,23 @@ async def test_song_detail_for_admin_shows_admin_action_buttons() -> None:
assert "Завантажити гармонію" in labels


@pytest.mark.asyncio
async def test_song_detail_suppresses_previews_for_visible_source_url() -> None:
song = build_song(source_url="https://example.org/source")
song_service = SimpleNamespace(get_song=AsyncMock(return_value=song))
context = build_context(song_service=song_service, admin_ids=(1,))
update, query = build_callback_update(data="song:detail:5:0", user_id=1)

await navigation_callback_router(update, context)

query.edit_message_text.assert_awaited_once()
assert (
"Джерело (оригінал): https://example.org/source"
in query.edit_message_text.await_args.args[0]
)
assert_link_previews_disabled(query.edit_message_text.await_args)


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