From 5f02471fd5690ca713e19c4772221b1413dbf5f7 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Oct 2025 03:19:54 +0300 Subject: [PATCH 1/3] Fix admin ticket DM links to support ID-only users --- app/handlers/admin/tickets.py | 33 ++++++++++----------- app/utils/telegram_links.py | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 app/utils/telegram_links.py diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index dfa1224d..43c1d9fc 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -22,6 +22,7 @@ from app.services.admin_notification_service import AdminNotificationService from app.services.support_settings_service import SupportSettingsService from app.config import settings from app.utils.cache import RateLimitCache +from app.utils.telegram_links import build_user_dm_url, build_user_profile_url logger = logging.getLogger(__name__) @@ -221,17 +222,14 @@ async def view_admin_ticket( pass # Кнопки ЛС и профиль try: - if ticket.user and ticket.user.telegram_id: + if ticket.user: buttons_row = [] - # DM: при наличии username используем tg://resolve, иначе fallback по ID - if ticket.user.username: - pm_url = f"tg://resolve?domain={ticket.user.username}" - else: - pm_url = f"tg://user?id={ticket.user.telegram_id}" - buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url)) - # Профиль: по ID - profile_url = f"tg://user?id={ticket.user.telegram_id}" - buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) + dm_url = build_user_dm_url(ticket.user.username, ticket.user.telegram_id) + profile_url = build_user_profile_url(ticket.user.username, ticket.user.telegram_id) + if dm_url: + buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=dm_url)) + if profile_url: + buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) if buttons_row: keyboard.inline_keyboard.insert(0, buttons_row) except Exception: @@ -778,15 +776,14 @@ async def handle_admin_block_duration_input( pass # Кнопки ЛС и профиль при обновлении карточки try: - if updated.user and updated.user.telegram_id: + if updated.user: buttons_row = [] - if updated.user.username: - pm_url = f"tg://resolve?domain={updated.user.username}" - else: - pm_url = f"tg://user?id={updated.user.telegram_id}" - buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url)) - profile_url = f"tg://user?id={updated.user.telegram_id}" - buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) + dm_url = build_user_dm_url(updated.user.username, updated.user.telegram_id) + profile_url = build_user_profile_url(updated.user.username, updated.user.telegram_id) + if dm_url: + buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=dm_url)) + if profile_url: + buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) if buttons_row: kb.inline_keyboard.insert(0, buttons_row) except Exception: diff --git a/app/utils/telegram_links.py b/app/utils/telegram_links.py new file mode 100644 index 00000000..321be4ff --- /dev/null +++ b/app/utils/telegram_links.py @@ -0,0 +1,56 @@ +"""Utilities for constructing Telegram deep links that work with usernames or IDs.""" + +from __future__ import annotations + +from typing import Optional + + +def _clean_username(username: str | None) -> str | None: + if not username: + return None + username_clean = username.strip().lstrip("@") + return username_clean or None + + +def build_user_dm_url(username: str | None, telegram_id: int | str | None) -> Optional[str]: + """Return a deep link for opening a DM with a Telegram user. + + Preference is given to usernames because the ``https://t.me/`` scheme + works reliably across platforms. When a username is not available, we fall back + to the ``tg://openmessage`` scheme with the numeric Telegram ID so that support + agents can still contact the user. + """ + + username_clean = _clean_username(username) + if username_clean: + return f"https://t.me/{username_clean}" + + if telegram_id is None: + return None + + try: + telegram_id_int = int(telegram_id) + except (TypeError, ValueError): + return None + + return f"tg://openmessage?user_id={telegram_id_int}" + + +def build_user_profile_url(username: str | None, telegram_id: int | str | None) -> Optional[str]: + """Return a link that opens a Telegram profile. + + When an ID is available we use the ``tg://user`` scheme to jump directly to the + user profile. If the ID cannot be parsed we reuse :func:`build_user_dm_url` as a + graceful fallback so that the caller always gets a usable link. + """ + + if telegram_id is not None: + try: + telegram_id_int = int(telegram_id) + except (TypeError, ValueError): + pass + else: + return f"tg://user?id={telegram_id_int}" + + return build_user_dm_url(username, telegram_id) + From 257c628cc81e6d99f977231be0485c683777cf82 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Oct 2025 03:27:38 +0300 Subject: [PATCH 2/3] Revert "Fix admin ticket DM links to support ID-only users" --- app/handlers/admin/tickets.py | 33 +++++++++++---------- app/utils/telegram_links.py | 56 ----------------------------------- 2 files changed, 18 insertions(+), 71 deletions(-) delete mode 100644 app/utils/telegram_links.py diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index 43c1d9fc..dfa1224d 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -22,7 +22,6 @@ from app.services.admin_notification_service import AdminNotificationService from app.services.support_settings_service import SupportSettingsService from app.config import settings from app.utils.cache import RateLimitCache -from app.utils.telegram_links import build_user_dm_url, build_user_profile_url logger = logging.getLogger(__name__) @@ -222,14 +221,17 @@ async def view_admin_ticket( pass # Кнопки ЛС и профиль try: - if ticket.user: + if ticket.user and ticket.user.telegram_id: buttons_row = [] - dm_url = build_user_dm_url(ticket.user.username, ticket.user.telegram_id) - profile_url = build_user_profile_url(ticket.user.username, ticket.user.telegram_id) - if dm_url: - buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=dm_url)) - if profile_url: - buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) + # DM: при наличии username используем tg://resolve, иначе fallback по ID + if ticket.user.username: + pm_url = f"tg://resolve?domain={ticket.user.username}" + else: + pm_url = f"tg://user?id={ticket.user.telegram_id}" + buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url)) + # Профиль: по ID + profile_url = f"tg://user?id={ticket.user.telegram_id}" + buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) if buttons_row: keyboard.inline_keyboard.insert(0, buttons_row) except Exception: @@ -776,14 +778,15 @@ async def handle_admin_block_duration_input( pass # Кнопки ЛС и профиль при обновлении карточки try: - if updated.user: + if updated.user and updated.user.telegram_id: buttons_row = [] - dm_url = build_user_dm_url(updated.user.username, updated.user.telegram_id) - profile_url = build_user_profile_url(updated.user.username, updated.user.telegram_id) - if dm_url: - buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=dm_url)) - if profile_url: - buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) + if updated.user.username: + pm_url = f"tg://resolve?domain={updated.user.username}" + else: + pm_url = f"tg://user?id={updated.user.telegram_id}" + buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url)) + profile_url = f"tg://user?id={updated.user.telegram_id}" + buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) if buttons_row: kb.inline_keyboard.insert(0, buttons_row) except Exception: diff --git a/app/utils/telegram_links.py b/app/utils/telegram_links.py deleted file mode 100644 index 321be4ff..00000000 --- a/app/utils/telegram_links.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Utilities for constructing Telegram deep links that work with usernames or IDs.""" - -from __future__ import annotations - -from typing import Optional - - -def _clean_username(username: str | None) -> str | None: - if not username: - return None - username_clean = username.strip().lstrip("@") - return username_clean or None - - -def build_user_dm_url(username: str | None, telegram_id: int | str | None) -> Optional[str]: - """Return a deep link for opening a DM with a Telegram user. - - Preference is given to usernames because the ``https://t.me/`` scheme - works reliably across platforms. When a username is not available, we fall back - to the ``tg://openmessage`` scheme with the numeric Telegram ID so that support - agents can still contact the user. - """ - - username_clean = _clean_username(username) - if username_clean: - return f"https://t.me/{username_clean}" - - if telegram_id is None: - return None - - try: - telegram_id_int = int(telegram_id) - except (TypeError, ValueError): - return None - - return f"tg://openmessage?user_id={telegram_id_int}" - - -def build_user_profile_url(username: str | None, telegram_id: int | str | None) -> Optional[str]: - """Return a link that opens a Telegram profile. - - When an ID is available we use the ``tg://user`` scheme to jump directly to the - user profile. If the ID cannot be parsed we reuse :func:`build_user_dm_url` as a - graceful fallback so that the caller always gets a usable link. - """ - - if telegram_id is not None: - try: - telegram_id_int = int(telegram_id) - except (TypeError, ValueError): - pass - else: - return f"tg://user?id={telegram_id_int}" - - return build_user_dm_url(username, telegram_id) - From 180788bc7b6411d5e89e413f1a80c67a888394ba Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Oct 2025 03:27:55 +0300 Subject: [PATCH 3/3] Fix admin ticket view for users without username --- app/handlers/admin/tickets.py | 51 ++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index dfa1224d..2e7e38cf 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, and_ from datetime import datetime, timedelta import time +import html from app.database.models import User, Ticket, TicketStatus from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD @@ -174,12 +175,24 @@ async def view_admin_ticket( user_name = ticket.user.full_name if ticket.user else "Unknown" telegram_id_display = ticket.user.telegram_id if ticket.user else "—" - username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует" + username_value = ticket.user.username if ticket.user else None ticket_text = f"🎫 Тикет #{ticket.id}\n\n" ticket_text += f"👤 Пользователь: {user_name}\n" ticket_text += f"🆔 Telegram ID: {telegram_id_display}\n" - ticket_text += f"📱 Username: @{username_display}\n" + if username_value: + safe_username = html.escape(username_value) + ticket_text += f"📱 Username: @{safe_username}\n" + ticket_text += ( + f"🔗 ЛС: " + f"tg://resolve?domain={safe_username}\n" + ) + else: + ticket_text += "📱 Username: отсутствует\n" + if ticket.user and ticket.user.telegram_id: + chat_link = f"tg://user?id={int(ticket.user.telegram_id)}" + ticket_text += f"🔗 Чат по ID: {chat_link}\n" + ticket_text += "\n" ticket_text += f"📝 Заголовок: {ticket.title}\n" ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n" ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n" @@ -221,15 +234,11 @@ async def view_admin_ticket( pass # Кнопки ЛС и профиль try: - if ticket.user and ticket.user.telegram_id: + if ticket.user and ticket.user.telegram_id and ticket.user.username: + safe_username = html.escape(ticket.user.username) buttons_row = [] - # DM: при наличии username используем tg://resolve, иначе fallback по ID - if ticket.user.username: - pm_url = f"tg://resolve?domain={ticket.user.username}" - else: - pm_url = f"tg://user?id={ticket.user.telegram_id}" + pm_url = f"tg://resolve?domain={safe_username}" buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url)) - # Профиль: по ID profile_url = f"tg://user?id={ticket.user.telegram_id}" buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) if buttons_row: @@ -750,7 +759,21 @@ async def handle_admin_block_duration_input( ticket_text += f"📝 Заголовок: {updated.title}\n" ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n" ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n" - ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" + ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n" + if updated.user and updated.user.telegram_id: + ticket_text += f"🆔 Telegram ID: {updated.user.telegram_id}\n" + if updated.user.username: + safe_username = html.escape(updated.user.username) + ticket_text += f"📱 Username: @{safe_username}\n" + ticket_text += ( + f"🔗 ЛС: " + f"tg://resolve?domain={safe_username}\n" + ) + else: + ticket_text += "📱 Username: отсутствует\n" + chat_link = f"tg://user?id={int(updated.user.telegram_id)}" + ticket_text += f"🔗 Чат по ID: {chat_link}\n" + ticket_text += "\n" if updated.is_user_reply_blocked: if updated.user_reply_block_permanent: ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n" @@ -778,12 +801,10 @@ async def handle_admin_block_duration_input( pass # Кнопки ЛС и профиль при обновлении карточки try: - if updated.user and updated.user.telegram_id: + if updated.user and updated.user.telegram_id and updated.user.username: + safe_username = html.escape(updated.user.username) buttons_row = [] - if updated.user.username: - pm_url = f"tg://resolve?domain={updated.user.username}" - else: - pm_url = f"tg://user?id={updated.user.telegram_id}" + pm_url = f"tg://resolve?domain={safe_username}" buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url)) profile_url = f"tg://user?id={updated.user.telegram_id}" buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))