feat: add admin telegram profile links and avatar preview
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Бан",
|
||||
|
||||
Reference in New Issue
Block a user