From 1d9f069f45b720e9a470b2ba08357a5b05188d7f Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 18 May 2026 23:46:46 +0300 Subject: [PATCH] feat: add admin telegram profile links and avatar preview --- backend/bot/app/web/admin_api_impl/routes.py | 4 + backend/bot/app/web/admin_api_impl/users.py | 105 ++++++++++ frontend/src/admin/AdminPanel.svelte | 6 + .../src/admin/sections/UserDetailModal.svelte | 182 +++++++++++++++++- frontend/src/lib/admin/stores/usersStore.js | 23 +++ frontend/src/lib/admin/users.js | 44 +++++ locales/en.json | 9 + locales/ru.json | 9 + 8 files changed, 373 insertions(+), 9 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 3a46e7b..4f325ad 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -15,6 +15,10 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post( "/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/telegram-profile-link", + admin_user_telegram_profile_link_route, + ) router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) router.add_post( diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 3c933d0..95073d2 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1,6 +1,10 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from html import escape as html_escape + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + async def admin_users_list_route(request: web.Request) -> web.Response: _require_admin_user_id(request) @@ -624,6 +628,107 @@ async def admin_user_message_preview_route(request: web.Request) -> web.Response return _ok({}) +def _admin_user_display_name_for_message(user: User) -> str: + full = " ".join( + part + for part in [getattr(user, "first_name", None), getattr(user, "last_name", None)] + if part + ).strip() + return ( + full + or (f"@{user.username}" if getattr(user, "username", None) else None) + or getattr(user, "email", None) + or f"User #{user.user_id}" + ) + + +async def admin_user_telegram_profile_link_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + admin_telegram_id = request.get("admin_telegram_id") + if not admin_telegram_id: + return _error(403, "admin_telegram_unavailable") + + queue_manager = get_queue_manager() + if not queue_manager: + return _error(503, "queue_unavailable") + + target_id = int(request.match_info["user_id"]) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + target_user = await user_dal.get_user_by_id(session, target_id) + if not target_user: + return _error(404, "not_found") + if not target_user.telegram_id: + return _error(404, "no_telegram_account") + + admin_user = await user_dal.get_user_by_id(session, actor_id) + lang = ( + getattr(admin_user, "language_code", None) + or getattr(settings, "DEFAULT_LANGUAGE", None) + or "ru" + ) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_profile_link_webapp", + "content": f"Requested Telegram profile link for user_id={target_id}", + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + + i18n_instance = request.app.get("i18n") + translate = ( + (lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)) + if i18n_instance is not None + else (lambda key, **kwargs: key.format(**kwargs) if kwargs else key) + ) + target_name = _admin_user_display_name_for_message(target_user) + telegram_id = int(target_user.telegram_id) + profile_url = f"tg://user?id={telegram_id}" + message_text = translate( + "admin_user_profile_link_message", + name=html_escape(target_name), + user_id=target_user.user_id, + telegram_id=telegram_id, + ) + if message_text == "admin_user_profile_link_message": + message_text = ( + f"Профиль пользователя: {html_escape(target_name)}\n" + f"User ID: {target_user.user_id}\n" + f"Telegram ID: {telegram_id}\n\n" + "Нажмите кнопку ниже, чтобы открыть профиль в Telegram." + ) + + button_text = translate("user_card_open_profile_button") + if button_text == "user_card_open_profile_button": + button_text = "👤 Открыть профиль" + + markup = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=button_text, url=profile_url)]] + ) + + try: + await send_message_via_queue( + queue_manager, + int(admin_telegram_id), + MessageContent(content_type="text", text=message_text), + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=markup, + ) + except Exception as exc: + logger.warning("Admin profile link message enqueue failed: %s", exc) + return _error(502, "send_failed", str(exc)) + + return _ok({"queued": True}) + + async def admin_user_delete_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index dac7d27..9ff5172 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -59,10 +59,13 @@ } from "../lib/admin/format.js"; import { createGravatarCache, + openTelegramProfileLink, userAvatarUrl, userDisplayName, userInitials, userSecondaryName, + userTelegramProfileLink, + userTelegramProfileLinkKind, } from "../lib/admin/users.js"; export let api; @@ -679,6 +682,9 @@ {userDisplayName} {userSecondaryName} {userInitials} + {userTelegramProfileLink} + {userTelegramProfileLinkKind} + {openTelegramProfileLink} {paymentStatusVariant} {trafficPercentValue} {trafficLeftLabel} diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index c5dc315..5eb1d39 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -15,6 +15,7 @@ import { Copy, Eye, + ExternalLink, RefreshCw, Send, Plus, @@ -36,6 +37,13 @@ export let trafficOfLabel; export let userInitials = () => ""; export let fmtDateShort = (v) => v; + export let userTelegramProfileLink = () => ""; + export let userTelegramProfileLinkKind = () => ""; + export let openTelegramProfileLink = () => false; + + let avatarPreviewOpen = false; + let avatarPreviewUrl = ""; + let avatarPreviewName = ""; function pretty(val) { if (val === true) return at("yes", {}, "Да"); @@ -67,9 +75,53 @@ $: userLogsHasMore = Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20); + $: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : ""; + $: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : ""; + $: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : ""; + $: openedUserTelegramProfileHint = + openedUserTelegramProfileLinkKind === "id" + ? at( + "user_open_tg_profile_id_hint", + {}, + "Профиль будет открыт по Telegram ID. Telegram может заблокировать переход из-за настроек приватности пользователя или ограничений клиента." + ) + : at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram"); + $: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) { usersStore.loadUserLogs(0); } + + $: if (!openedUser) { + avatarPreviewOpen = false; + avatarPreviewUrl = ""; + avatarPreviewName = ""; + } + + function openAvatarPreview() { + if (!openedUserAvatarUrl || !openedUser) return; + avatarPreviewUrl = openedUserAvatarUrl; + avatarPreviewName = userDisplayName(openedUser); + avatarPreviewOpen = true; + } + + function closeAvatarPreview() { + avatarPreviewOpen = false; + } + + function openUserTelegramProfile() { + if (!openedUserTelegramProfileLink) { + usersStore.copyToClipboard( + String(openedUser?.telegram_id || ""), + at("user_tg_profile_unavailable", {}, "Ссылка на профиль Telegram недоступна") + ); + return; + } + if (openedUserTelegramProfileLinkKind === "id") { + usersStore.sendTelegramProfileLink(); + return; + } + openTelegramProfileLink(openedUserTelegramProfileLink); + } + + {#if avatarPreviewUrl} +
+ {avatarPreviewName} +
+ {/if} +
+ { + s = st; + return st; + }); + if (!s.openedUser) return; + state.update((st) => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}/telegram-profile-link`, { + method: "POST", + }); + if (res?.ok) { + onToast(at("user_tg_profile_link_sent", {}, "Ссылка отправлена в Telegram")); + } else { + onToast(res?.error || at("user_tg_profile_link_failed", {}, "Не удалось отправить ссылку")); + } + } finally { + state.update((st) => ({ ...st, userActionBusy: false })); + } + } + async function extendUser() { let s; state.update((st) => { @@ -510,6 +532,7 @@ export function createUsersStore({ api, onToast, at }) { sendUserMessage, requestSendUserMessage, previewUserMessage, + sendTelegramProfileLink, extendUser, resetTrialUser, deleteUser, diff --git a/frontend/src/lib/admin/users.js b/frontend/src/lib/admin/users.js index f78a8cf..876d398 100644 --- a/frontend/src/lib/admin/users.js +++ b/frontend/src/lib/admin/users.js @@ -25,6 +25,50 @@ export function userAvatarUrl(user) { return value && !value.startsWith("/api/account/avatar") ? value : ""; } +export function userTelegramProfileLink(user) { + const username = String(user?.username || "") + .trim() + .replace(/^@+/, ""); + if (username) return `https://t.me/${encodeURIComponent(username)}`; + + const telegramId = Number(user?.telegram_id); + if (Number.isFinite(telegramId) && telegramId > 0) { + return `tg://user?id=${encodeURIComponent(String(Math.trunc(telegramId)))}`; + } + + return ""; +} + +export function userTelegramProfileLinkKind(user) { + return String(user?.username || "").trim() + ? "username" + : userTelegramProfileLink(user) + ? "id" + : ""; +} + +export function openTelegramProfileLink(link) { + if (!link || typeof window === "undefined") return false; + + const tg = window.Telegram?.WebApp; + if (/^https:\/\/(?:t|telegram)\.me\//i.test(link) && typeof tg?.openTelegramLink === "function") { + try { + tg.openTelegramLink(link); + return true; + } catch { + // Fall through to the normal browser/deep-link handling below. + } + } + + if (/^https?:\/\//i.test(link)) { + window.open(link, "_blank", "noopener,noreferrer"); + return true; + } + + window.location.href = link; + return true; +} + export function createGravatarCache(onResolved = () => {}) { const cache = new Map(); const pending = new Map(); diff --git a/locales/en.json b/locales/en.json index c291db4..6ba9613 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1057,6 +1057,15 @@ "admin_status_disabled": "Disabled", "admin_status_bot_only": "Bot Only", "admin_user_detail_title": "User #{id}", + "admin_user_avatar_open": "Open avatar", + "admin_user_avatar_title": "Avatar", + "admin_user_open_tg_profile": "Open Telegram", + "admin_user_open_tg_profile_hint": "Open Telegram profile", + "admin_user_open_tg_profile_id_hint": "This user has no username: the bot will send you a Telegram message with a profile button.", + "admin_user_tg_profile_unavailable": "Telegram profile link is unavailable", + "admin_user_tg_profile_link_sent": "Link sent to Telegram", + "admin_user_tg_profile_link_failed": "Failed to send link", + "admin_user_profile_link_message": "User profile: {name}\nUser ID: {user_id}\nTelegram ID: {telegram_id}\n\nTap the button below to open the profile in Telegram.", "admin_close": "Close", "admin_loading": "Loading…", "admin_badge_banned": "Banned", diff --git a/locales/ru.json b/locales/ru.json index 5df8e5c..c9dd015 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1057,6 +1057,15 @@ "admin_status_disabled": "Выключен", "admin_status_bot_only": "Только бот", "admin_user_detail_title": "Пользователь #{id}", + "admin_user_avatar_open": "Открыть аватар", + "admin_user_avatar_title": "Аватар", + "admin_user_open_tg_profile": "Открыть Telegram", + "admin_user_open_tg_profile_hint": "Открыть профиль Telegram", + "admin_user_open_tg_profile_id_hint": "Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.", + "admin_user_tg_profile_unavailable": "Ссылка на профиль Telegram недоступна", + "admin_user_tg_profile_link_sent": "Ссылка отправлена в Telegram", + "admin_user_tg_profile_link_failed": "Не удалось отправить ссылку", + "admin_user_profile_link_message": "Профиль пользователя: {name}\nUser ID: {user_id}\nTelegram ID: {telegram_id}\n\nНажмите кнопку ниже, чтобы открыть профиль в Telegram.", "admin_close": "Закрыть", "admin_loading": "Загрузка…", "admin_badge_banned": "Бан",