feat: add admin telegram profile links and avatar preview

This commit is contained in:
3252a8
2026-05-18 23:46:46 +03:00
parent 707f569f62
commit 1d9f069f45
8 changed files with 373 additions and 9 deletions
@@ -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(
+105
View File
@@ -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"Профиль пользователя: <b>{html_escape(target_name)}</b>\n"
f"User ID: <code>{target_user.user_id}</code>\n"
f"Telegram ID: <code>{telegram_id}</code>\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"])
+6
View File
@@ -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}
@@ -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);
}
</script>
<Dialog
@@ -89,18 +141,21 @@
<div class="admin-user-dialog-body">
<aside class="admin-user-aside">
<div class="admin-user-summary">
<span class="admin-avatar admin-avatar-lg">
{#if resolvedAvatarUrl(openedUser)}
<img
src={resolvedAvatarUrl(openedUser)}
alt=""
loading="lazy"
referrerpolicy="no-referrer"
/>
<button
type="button"
class="admin-avatar admin-avatar-lg admin-avatar-preview-trigger"
class:is-clickable={Boolean(openedUserAvatarUrl)}
disabled={!openedUserAvatarUrl}
onclick={openAvatarPreview}
aria-label={at("user_avatar_open", {}, "Открыть аватар")}
title={openedUserAvatarUrl ? at("user_avatar_open", {}, "Открыть аватар") : ""}
>
{#if openedUserAvatarUrl}
<img src={openedUserAvatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
<span>{userInitials(openedUser)}</span>
{/if}
</span>
</button>
<div class="admin-user-summary-meta">
<strong>{userDisplayName(openedUser)}</strong>
<small>{userSecondaryName(openedUser)}</small>
@@ -120,6 +175,24 @@
>
{/if}
</div>
<div class="admin-user-summary-actions">
<AdminButton
size="sm"
variant="ghost"
onclick={openUserTelegramProfile}
disabled={!openedUserTelegramProfileLink}
title={openedUserTelegramProfileHint}
aria-label={at("user_open_tg_profile", {}, "Открыть профиль Telegram")}
>
<ExternalLink size={14} />
{at("user_open_tg_profile", {}, "Открыть Telegram")}
</AdminButton>
</div>
{#if openedUserTelegramProfileLinkKind === "id"}
<small class="admin-user-telegram-profile-note"
>{openedUserTelegramProfileHint}</small
>
{/if}
</div>
</div>
@@ -871,6 +944,25 @@
{/if}
</Dialog>
<Dialog
open={avatarPreviewOpen}
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
closeLabel={at("close", {}, "Закрыть")}
onclose={closeAvatarPreview}
class="admin-dialog admin-avatar-dialog"
>
{#if avatarPreviewUrl}
<div class="admin-avatar-preview">
<img
src={avatarPreviewUrl}
alt={avatarPreviewName}
loading="eager"
referrerpolicy="no-referrer"
/>
</div>
{/if}
</Dialog>
<Dialog
open={userMessageConfirmOpen}
title={at("user_msg_confirm_title", {}, "Отправить сообщение пользователю?")}
@@ -1054,6 +1146,78 @@
width: 100%;
max-width: 100%;
}
.admin-avatar-preview-trigger {
padding: 0;
appearance: none;
}
.admin-avatar-preview-trigger img {
width: 100%;
height: 100%;
object-fit: cover;
}
.admin-avatar-preview-trigger:disabled {
cursor: default;
}
.admin-avatar-preview-trigger.is-clickable {
cursor: zoom-in;
}
.admin-avatar-preview-trigger.is-clickable:hover,
.admin-avatar-preview-trigger.is-clickable:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
.admin-user-summary-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.admin-user-telegram-profile-note {
display: block;
margin-top: 2px;
color: var(--admin-dim);
line-height: 1.35;
}
:global(.admin-avatar-dialog) {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(920px, calc(100vw - 28px));
height: min(820px, calc(100dvh - 28px));
max-height: calc(100dvh - 28px);
gap: 10px;
padding: 12px;
overflow: hidden;
}
.admin-avatar-preview {
display: grid;
place-items: center;
min-height: 0;
width: 100%;
height: 100%;
padding: 4px;
overflow: hidden;
}
.admin-avatar-preview img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 14px;
border: 1px solid var(--admin-border);
background: var(--admin-surface-2);
}
@media (max-width: 640px) {
:global(.dialog:has(.admin-avatar-dialog)) {
padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right))
max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
}
:global(.admin-avatar-dialog) {
width: calc(100vw - 16px);
height: min(88dvh, calc(100dvh - 16px));
max-height: calc(100dvh - 16px);
border-radius: 18px;
padding: 10px;
}
}
:global(.admin-user-dialog .admin-user-logs-tab) {
display: flex;
flex-direction: column;
@@ -319,6 +319,28 @@ export function createUsersStore({ api, onToast, at }) {
}
}
async function sendTelegramProfileLink() {
let s;
state.update((st) => {
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,
+44
View File
@@ -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();
+9
View File
@@ -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: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\n\nTap the button below to open the profile in Telegram.",
"admin_close": "Close",
"admin_loading": "Loading…",
"admin_badge_banned": "Banned",
+9
View File
@@ -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": "Профиль пользователя: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\n\nНажмите кнопку ниже, чтобы открыть профиль в Telegram.",
"admin_close": "Закрыть",
"admin_loading": "Загрузка…",
"admin_badge_banned": "Бан",