Add info if linked acc

This commit is contained in:
2025-12-16 20:19:45 +07:00
parent 412de3bf05
commit ca41c207b3
9 changed files with 302 additions and 45 deletions

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
*/node_modules
# Build outputs
dist
build
*.pyc
__pycache__
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
# OS files
.DS_Store
Thumbs.db
# Test & coverage
coverage
.pytest_cache
.coverage
# Misc
*.md
!README.md

View File

@@ -0,0 +1,30 @@
"""Add telegram profile fields to users
Revision ID: 010_add_telegram_profile
Revises: 009_add_disputes
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '010_add_telegram_profile'
down_revision: Union[str, None] = '009_add_disputes'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'telegram_avatar_url')
op.drop_column('users', 'telegram_last_name')
op.drop_column('users', 'telegram_first_name')

View File

@@ -25,6 +25,9 @@ class TelegramConfirmLink(BaseModel):
token: str token: str
telegram_id: int telegram_id: int
telegram_username: str | None = None telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
class TelegramLinkResponse(BaseModel): class TelegramLinkResponse(BaseModel):
@@ -131,6 +134,9 @@ async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}") logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
user.telegram_id = data.telegram_id user.telegram_id = data.telegram_id
user.telegram_username = data.telegram_username user.telegram_username = data.telegram_username
user.telegram_first_name = data.telegram_first_name
user.telegram_last_name = data.telegram_last_name
user.telegram_avatar_url = data.telegram_avatar_url
await db.commit() await db.commit()
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}") logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")

View File

@@ -21,6 +21,9 @@ class User(Base):
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True) avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True) telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True) telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
telegram_first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value) role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -35,6 +35,9 @@ class UserPublic(UserBase):
role: str = "user" role: str = "user"
telegram_id: int | None = None telegram_id: int | None = None
telegram_username: str | None = None telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
created_at: datetime created_at: datetime
class Config: class Config:

View File

@@ -1,9 +1,10 @@
import logging import logging
from aiogram import Router, F from aiogram import Router, F, Bot
from aiogram.filters import CommandStart, Command, CommandObject from aiogram.filters import CommandStart, Command, CommandObject
from aiogram.types import Message from aiogram.types import Message
from config import settings
from keyboards.main_menu import get_main_menu from keyboards.main_menu import get_main_menu
from services.api_client import api_client from services.api_client import api_client
@@ -11,6 +12,21 @@ logger = logging.getLogger(__name__)
router = Router() router = Router()
async def get_user_avatar_url(bot: Bot, user_id: int) -> str | None:
"""Get user's Telegram profile photo URL."""
try:
photos = await bot.get_user_profile_photos(user_id, limit=1)
if photos.total_count > 0 and photos.photos:
# Get the largest photo (last in the list)
photo = photos.photos[0][-1]
file = await bot.get_file(photo.file_id)
if file.file_path:
return f"https://api.telegram.org/file/bot{settings.TELEGRAM_BOT_TOKEN}/{file.file_path}"
except Exception as e:
logger.warning(f"[START] Could not get user avatar: {e}")
return None
@router.message(CommandStart()) @router.message(CommandStart())
async def cmd_start(message: Message, command: CommandObject): async def cmd_start(message: Message, command: CommandObject):
"""Handle /start command with or without deep link.""" """Handle /start command with or without deep link."""
@@ -26,16 +42,26 @@ async def cmd_start(message: Message, command: CommandObject):
logger.info(f"[START] Token: {token}") logger.info(f"[START] Token: {token}")
logger.info(f"[START] Token length: {len(token)} chars") logger.info(f"[START] Token length: {len(token)} chars")
# Get user's avatar
avatar_url = await get_user_avatar_url(message.bot, message.from_user.id)
logger.info(f"[START] User avatar URL: {avatar_url}")
logger.info(f"[START] -------- CALLING API --------") logger.info(f"[START] -------- CALLING API --------")
logger.info(f"[START] Sending to /telegram/confirm-link:") logger.info(f"[START] Sending to /telegram/confirm-link:")
logger.info(f"[START] - token: {token}") logger.info(f"[START] - token: {token}")
logger.info(f"[START] - telegram_id: {message.from_user.id}") logger.info(f"[START] - telegram_id: {message.from_user.id}")
logger.info(f"[START] - telegram_username: {message.from_user.username}") logger.info(f"[START] - telegram_username: {message.from_user.username}")
logger.info(f"[START] - telegram_first_name: {message.from_user.first_name}")
logger.info(f"[START] - telegram_last_name: {message.from_user.last_name}")
logger.info(f"[START] - telegram_avatar_url: {avatar_url}")
result = await api_client.confirm_telegram_link( result = await api_client.confirm_telegram_link(
token=token, token=token,
telegram_id=message.from_user.id, telegram_id=message.from_user.id,
telegram_username=message.from_user.username telegram_username=message.from_user.username,
telegram_first_name=message.from_user.first_name,
telegram_last_name=message.from_user.last_name,
telegram_avatar_url=avatar_url
) )
logger.info(f"[START] -------- API RESPONSE --------") logger.info(f"[START] -------- API RESPONSE --------")

View File

@@ -64,7 +64,10 @@ class APIClient:
self, self,
token: str, token: str,
telegram_id: int, telegram_id: int,
telegram_username: str | None telegram_username: str | None,
telegram_first_name: str | None = None,
telegram_last_name: str | None = None,
telegram_avatar_url: str | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Confirm Telegram account linking.""" """Confirm Telegram account linking."""
result = await self._request( result = await self._request(
@@ -73,7 +76,10 @@ class APIClient:
json={ json={
"token": token, "token": token,
"telegram_id": telegram_id, "telegram_id": telegram_id,
"telegram_username": telegram_username "telegram_username": telegram_username,
"telegram_first_name": telegram_first_name,
"telegram_last_name": telegram_last_name,
"telegram_avatar_url": telegram_avatar_url
} }
) )
return result or {"error": "Не удалось связаться с сервером"} return result or {"error": "Не удалось связаться с сервером"}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState, useEffect, useRef } from 'react'
import { MessageCircle, ExternalLink, X, Loader2 } from 'lucide-react' import { MessageCircle, ExternalLink, X, Loader2, RefreshCw, CheckCircle, User, Link2, Link2Off } from 'lucide-react'
import { telegramApi } from '@/api/telegram' import { telegramApi } from '@/api/telegram'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
export function TelegramLink() { export function TelegramLink() {
@@ -9,16 +10,74 @@ export function TelegramLink() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [botUrl, setBotUrl] = useState<string | null>(null) const [botUrl, setBotUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [isPolling, setIsPolling] = useState(false)
const [linkSuccess, setLinkSuccess] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, [])
const startPolling = () => {
setIsPolling(true)
let attempts = 0
const maxAttempts = 60 // 5 minutes (5 sec intervals)
pollingRef.current = setInterval(async () => {
attempts++
try {
const userData = await authApi.me()
if (userData.telegram_id) {
// Success! User linked their account
updateUser({
telegram_id: userData.telegram_id,
telegram_username: userData.telegram_username,
telegram_first_name: userData.telegram_first_name,
telegram_last_name: userData.telegram_last_name,
telegram_avatar_url: userData.telegram_avatar_url
})
setLinkSuccess(true)
setIsPolling(false)
setBotUrl(null)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
} catch {
// Ignore errors, continue polling
}
if (attempts >= maxAttempts) {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, 5000)
}
const stopPolling = () => {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
const handleGenerateLink = async () => { const handleGenerateLink = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
setLinkSuccess(false)
try { try {
const { bot_url } = await telegramApi.generateLinkToken() const { bot_url } = await telegramApi.generateLinkToken()
setBotUrl(bot_url) setBotUrl(bot_url)
} catch (err) { } catch {
setError('Не удалось сгенерировать ссылку') setError('Не удалось сгенерировать ссылку')
} finally { } finally {
setLoading(false) setLoading(false)
@@ -30,9 +89,15 @@ export function TelegramLink() {
setError(null) setError(null)
try { try {
await telegramApi.unlinkTelegram() await telegramApi.unlinkTelegram()
updateUser({ telegram_id: null, telegram_username: null }) updateUser({
telegram_id: null,
telegram_username: null,
telegram_first_name: null,
telegram_last_name: null,
telegram_avatar_url: null
})
setIsOpen(false) setIsOpen(false)
} catch (err) { } catch {
setError('Не удалось отвязать аккаунт') setError('Не удалось отвязать аккаунт')
} finally { } finally {
setLoading(false) setLoading(false)
@@ -42,11 +107,18 @@ export function TelegramLink() {
const handleOpenBot = () => { const handleOpenBot = () => {
if (botUrl) { if (botUrl) {
window.open(botUrl, '_blank') window.open(botUrl, '_blank')
setIsOpen(false) startPolling()
setBotUrl(null)
} }
} }
const handleClose = () => {
setIsOpen(false)
setBotUrl(null)
setError(null)
setLinkSuccess(false)
stopPolling()
}
return ( return (
<> <>
<button <button
@@ -65,11 +137,7 @@ export function TelegramLink() {
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative"> <div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative">
<button <button
onClick={() => { onClick={handleClose}
setIsOpen(false)
setBotUrl(null)
setError(null)
}}
className="absolute top-4 right-4 text-gray-400 hover:text-white" className="absolute top-4 right-4 text-gray-400 hover:text-white"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
@@ -93,53 +161,124 @@ export function TelegramLink() {
</div> </div>
)} )}
{isLinked ? ( {isLinked || linkSuccess ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="p-4 bg-gray-700/50 rounded-lg"> {linkSuccess && (
<p className="text-sm text-gray-400 mb-1">Привязан к:</p> <div className="p-4 bg-green-500/20 border border-green-500/50 rounded-lg flex items-center gap-3">
<p className="text-white font-medium"> <CheckCircle className="w-6 h-6 text-green-400 flex-shrink-0" />
{user?.telegram_username ? `@${user.telegram_username}` : `ID: ${user?.telegram_id}`} <p className="text-green-400 font-medium">Аккаунт успешно привязан!</p>
</p> </div>
)}
{/* User Profile Card */}
<div className="p-4 bg-gradient-to-br from-gray-700/50 to-gray-800/50 rounded-xl border border-gray-600/50">
<div className="flex items-center gap-4">
{/* Avatar - prefer Telegram avatar */}
<div className="relative">
{user?.telegram_avatar_url || user?.avatar_url ? (
<img
src={user.telegram_avatar_url || user.avatar_url || ''}
alt={user.nickname}
className="w-16 h-16 rounded-full object-cover border-2 border-blue-500/50"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-8 h-8 text-white" />
</div>
)}
{/* Link indicator */}
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800">
<Link2 className="w-3 h-3 text-white" />
</div>
</div>
{/* User Info */}
<div className="flex-1 min-w-0">
<p className="text-lg font-bold text-white truncate">
{[user?.telegram_first_name, user?.telegram_last_name].filter(Boolean).join(' ') || user?.nickname}
</p>
{user?.telegram_username && (
<p className="text-blue-400 font-medium truncate">@{user.telegram_username}</p>
)}
</div>
</div>
</div> </div>
<div className="text-sm text-gray-400"> {/* Notifications Info */}
<p className="mb-2">Ты будешь получать уведомления о:</p> <div className="p-4 bg-gray-700/30 rounded-lg">
<ul className="list-disc list-inside space-y-1"> <p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
<li>Начале и окончании событий</li> <div className="grid grid-cols-1 gap-2">
<li>Старте и завершении марафонов</li> <div className="flex items-center gap-2 text-sm text-gray-400">
<li>Спорах по заданиям</li> <span className="text-yellow-400">🌟</span>
</ul> <span>События (Golden Hour, Jackpot)</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-green-400">🚀</span>
<span>Старт и финиш марафонов</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-red-400"></span>
<span>Споры по заданиям</span>
</div>
</div>
</div> </div>
<button <button
onClick={handleUnlink} onClick={handleUnlink}
disabled={loading} disabled={loading}
className="w-full py-3 px-4 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg font-medium transition-colors disabled:opacity-50" className="w-full py-3 px-4 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2 border border-red-500/30"
> >
{loading ? ( {loading ? (
<Loader2 className="w-5 h-5 animate-spin mx-auto" /> <Loader2 className="w-5 h-5 animate-spin" />
) : ( ) : (
'Отвязать аккаунт' <>
<Link2Off className="w-4 h-4" />
Отвязать аккаунт
</>
)} )}
</button> </button>
</div> </div>
) : botUrl ? ( ) : botUrl ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-gray-300"> {isPolling ? (
Нажми кнопку ниже, чтобы открыть бота и завершить привязку: <>
</p> <div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
<div className="flex items-center gap-3 mb-2">
<RefreshCw className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400 font-medium">Ожидание привязки...</p>
</div>
<p className="text-sm text-gray-400">
Открой бота в Telegram и нажми Start. Статус обновится автоматически.
</p>
</div>
<button <button
onClick={handleOpenBot} onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
> >
<ExternalLink className="w-5 h-5" /> <ExternalLink className="w-5 h-5" />
Открыть Telegram Открыть Telegram снова
</button> </button>
</>
) : (
<>
<p className="text-gray-300">
Нажми кнопку ниже, чтобы открыть бота и завершить привязку:
</p>
<p className="text-sm text-gray-500 text-center"> <button
Ссылка действительна 10 минут onClick={handleOpenBot}
</p> className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram
</button>
<p className="text-sm text-gray-500 text-center">
Ссылка действительна 10 минут
</p>
</>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -9,6 +9,9 @@ export interface User {
role: UserRole role: UserRole
telegram_id: number | null telegram_id: number | null
telegram_username: string | null telegram_username: string | null
telegram_first_name: string | null
telegram_last_name: string | null
telegram_avatar_url: string | null
created_at: string created_at: string
} }