From ca41c207b33dc9604e47dddb37e17f213a271f70 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Tue, 16 Dec 2025 20:19:45 +0700 Subject: [PATCH] Add info if linked acc --- .dockerignore | 41 ++++ .../versions/010_add_telegram_profile.py | 30 +++ backend/app/api/v1/telegram.py | 6 + backend/app/models/user.py | 3 + backend/app/schemas/user.py | 3 + bot/handlers/start.py | 30 ++- bot/services/api_client.py | 10 +- frontend/src/components/TelegramLink.tsx | 221 ++++++++++++++---- frontend/src/types/index.ts | 3 + 9 files changed, 302 insertions(+), 45 deletions(-) create mode 100644 .dockerignore create mode 100644 backend/alembic/versions/010_add_telegram_profile.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1510aa0 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/backend/alembic/versions/010_add_telegram_profile.py b/backend/alembic/versions/010_add_telegram_profile.py new file mode 100644 index 0000000..2feeaf5 --- /dev/null +++ b/backend/alembic/versions/010_add_telegram_profile.py @@ -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') diff --git a/backend/app/api/v1/telegram.py b/backend/app/api/v1/telegram.py index e441b38..aa70ced 100644 --- a/backend/app/api/v1/telegram.py +++ b/backend/app/api/v1/telegram.py @@ -25,6 +25,9 @@ class TelegramConfirmLink(BaseModel): token: str telegram_id: int 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): @@ -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}") user.telegram_id = data.telegram_id 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() logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 32cb4b1..88feaaf 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -21,6 +21,9 @@ class User(Base): avatar_path: Mapped[str | None] = mapped_column(String(500), 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_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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index c4b9b62..30ac25a 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -35,6 +35,9 @@ class UserPublic(UserBase): role: str = "user" telegram_id: int | 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 class Config: diff --git a/bot/handlers/start.py b/bot/handlers/start.py index c898422..e9917b7 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -1,9 +1,10 @@ import logging -from aiogram import Router, F +from aiogram import Router, F, Bot from aiogram.filters import CommandStart, Command, CommandObject from aiogram.types import Message +from config import settings from keyboards.main_menu import get_main_menu from services.api_client import api_client @@ -11,6 +12,21 @@ logger = logging.getLogger(__name__) 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()) async def cmd_start(message: Message, command: CommandObject): """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 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] Sending to /telegram/confirm-link:") logger.info(f"[START] - token: {token}") 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_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( token=token, 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 --------") diff --git a/bot/services/api_client.py b/bot/services/api_client.py index 97d02d1..e86ae07 100644 --- a/bot/services/api_client.py +++ b/bot/services/api_client.py @@ -64,7 +64,10 @@ class APIClient: self, token: str, 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]: """Confirm Telegram account linking.""" result = await self._request( @@ -73,7 +76,10 @@ class APIClient: json={ "token": token, "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": "Не удалось связаться с сервером"} diff --git a/frontend/src/components/TelegramLink.tsx b/frontend/src/components/TelegramLink.tsx index edb606b..7ff3337 100644 --- a/frontend/src/components/TelegramLink.tsx +++ b/frontend/src/components/TelegramLink.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' -import { MessageCircle, ExternalLink, X, Loader2 } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { MessageCircle, ExternalLink, X, Loader2, RefreshCw, CheckCircle, User, Link2, Link2Off } from 'lucide-react' import { telegramApi } from '@/api/telegram' +import { authApi } from '@/api/auth' import { useAuthStore } from '@/store/auth' export function TelegramLink() { @@ -9,16 +10,74 @@ export function TelegramLink() { const [loading, setLoading] = useState(false) const [botUrl, setBotUrl] = useState(null) const [error, setError] = useState(null) + const [isPolling, setIsPolling] = useState(false) + const [linkSuccess, setLinkSuccess] = useState(false) + const pollingRef = useRef | null>(null) 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 () => { setLoading(true) setError(null) + setLinkSuccess(false) try { const { bot_url } = await telegramApi.generateLinkToken() setBotUrl(bot_url) - } catch (err) { + } catch { setError('Не удалось сгенерировать ссылку') } finally { setLoading(false) @@ -30,9 +89,15 @@ export function TelegramLink() { setError(null) try { 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) - } catch (err) { + } catch { setError('Не удалось отвязать аккаунт') } finally { setLoading(false) @@ -42,11 +107,18 @@ export function TelegramLink() { const handleOpenBot = () => { if (botUrl) { window.open(botUrl, '_blank') - setIsOpen(false) - setBotUrl(null) + startPolling() } } + const handleClose = () => { + setIsOpen(false) + setBotUrl(null) + setError(null) + setLinkSuccess(false) + stopPolling() + } + return ( <> ) : botUrl ? (
-

- Нажми кнопку ниже, чтобы открыть бота и завершить привязку: -

+ {isPolling ? ( + <> +
+
+ +

Ожидание привязки...

+
+

+ Открой бота в Telegram и нажми Start. Статус обновится автоматически. +

+
- + + + ) : ( + <> +

+ Нажми кнопку ниже, чтобы открыть бота и завершить привязку: +

-

- Ссылка действительна 10 минут -

+ + +

+ Ссылка действительна 10 минут +

+ + )}
) : (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 80792b4..b51f4fa 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -9,6 +9,9 @@ export interface User { role: UserRole telegram_id: number | null telegram_username: string | null + telegram_first_name: string | null + telegram_last_name: string | null + telegram_avatar_url: string | null created_at: string }