Initial commit: Telegram Web Client with bot chat sync
- Backend: FastAPI + Telethon v2 WebSocket server - Frontend: React + TypeScript + Vite + Zustand - Features: Phone auth, 2FA, real-time bot chat - Fix: Use chats= instead of from_users= to sync messages from all devices - Config: BOT_USERNAME=AnvilForgeBot Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"haproxy": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://100.108.39.107:8000/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Telegram Web Client
|
||||||
|
|
||||||
|
웹소켓을 이용해 Telegram 봇(telegram-bot-workers)과 대화하는 웹 앱
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Backend**: Python + Telethon v2 + FastAPI WebSocket
|
||||||
|
- **Frontend**: React + TypeScript + Vite + Zustand
|
||||||
|
- **Infrastructure**: Incus 컨테이너 (Debian 13) on jp1
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Telegram API 자격증명 획득
|
||||||
|
|
||||||
|
1. https://my.telegram.org 접속
|
||||||
|
2. "API development tools" 클릭
|
||||||
|
3. `API_ID`와 `API_HASH` 복사
|
||||||
|
|
||||||
|
### 2. 환경 변수 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 컨테이너에 접속
|
||||||
|
incus exec jp1:telegram-web-client -- bash
|
||||||
|
|
||||||
|
# .env 파일 생성
|
||||||
|
cd /opt/telegram-web-client/backend
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 편집
|
||||||
|
vi .env
|
||||||
|
# TELEGRAM_API_ID=your_api_id
|
||||||
|
# TELEGRAM_API_HASH=your_api_hash
|
||||||
|
# BOT_USERNAME=telegram_summary_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 서비스 시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# systemd 서비스 등록
|
||||||
|
incus file push telegram-web-client.service jp1:telegram-web-client/etc/systemd/system/
|
||||||
|
|
||||||
|
# 서비스 활성화 및 시작
|
||||||
|
incus exec jp1:telegram-web-client -- systemctl daemon-reload
|
||||||
|
incus exec jp1:telegram-web-client -- systemctl enable telegram-web-client
|
||||||
|
incus exec jp1:telegram-web-client -- systemctl start telegram-web-client
|
||||||
|
|
||||||
|
# 상태 확인
|
||||||
|
incus exec jp1:telegram-web-client -- systemctl status telegram-web-client
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 프론트엔드 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 컨테이너 내에서
|
||||||
|
cd /opt/telegram-web-client/frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### 로컬 개발 (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your credentials
|
||||||
|
uvicorn main:app --reload --port 8000
|
||||||
|
|
||||||
|
# Frontend (별도 터미널)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컨테이너 접속
|
||||||
|
|
||||||
|
```bash
|
||||||
|
incus exec jp1:telegram-web-client -- bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
incus exec jp1:telegram-web-client -- journalctl -u telegram-web-client -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ WebSocket ┌─────────────────────────────┐
|
||||||
|
│ React Frontend │◄──────────────────►│ FastAPI Backend (Debian) │
|
||||||
|
│ (브라우저) │ │ │
|
||||||
|
└─────────────────┘ │ ┌─────────────────────┐ │
|
||||||
|
│ │ Telethon v2 Client │ │
|
||||||
|
│ │ (MTProto) │ │
|
||||||
|
│ └──────────┬──────────┘ │
|
||||||
|
└─────────────┼───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Telegram API (MTProto) │
|
||||||
|
│ ↔ telegram-bot-workers 봇 │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket Messages
|
||||||
|
|
||||||
|
### Client → Server
|
||||||
|
|
||||||
|
| Type | Data | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `check_auth` | `{}` | 인증 상태 확인 |
|
||||||
|
| `send_code` | `{phone}` | 인증 코드 전송 요청 |
|
||||||
|
| `verify_code` | `{phone, code, phone_code_hash}` | 코드 검증 |
|
||||||
|
| `verify_password` | `{password}` | 2FA 비밀번호 검증 |
|
||||||
|
| `send_message` | `{text}` | 메시지 전송 |
|
||||||
|
| `get_history` | `{}` | 채팅 기록 조회 |
|
||||||
|
|
||||||
|
### Server → Client
|
||||||
|
|
||||||
|
| Type | Data | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `connected` | `{session_id}` | 연결 완료 |
|
||||||
|
| `auth_status` | `{authenticated, user_id, ...}` | 인증 상태 |
|
||||||
|
| `code_sent` | `{phone, phone_code_hash}` | 코드 전송됨 |
|
||||||
|
| `need_password` | `{message}` | 2FA 필요 |
|
||||||
|
| `auth_success` | `{user_id, username, ...}` | 인증 성공 |
|
||||||
|
| `auth_error` | `{message}` | 인증 실패 |
|
||||||
|
| `history` | `{messages}` | 채팅 기록 |
|
||||||
|
| `message_sent` | `{id, text, date, ...}` | 메시지 전송됨 |
|
||||||
|
| `new_message` | `{id, text, date, ...}` | 새 메시지 수신 |
|
||||||
|
|
||||||
|
## Container Info
|
||||||
|
|
||||||
|
- **Name**: `jp1:telegram-web-client`
|
||||||
|
- **IP**: `incus exec jp1:telegram-web-client -- ip a`
|
||||||
|
- **Port**: 8000 (Backend API/WebSocket)
|
||||||
13
backend/.env.example
Normal file
13
backend/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Telegram API credentials (get from https://my.telegram.org)
|
||||||
|
TELEGRAM_API_ID=12345678
|
||||||
|
TELEGRAM_API_HASH=your_api_hash_here
|
||||||
|
|
||||||
|
# Bot username to chat with (without @)
|
||||||
|
BOT_USERNAME=AnvilForgeBot
|
||||||
|
|
||||||
|
# Session storage directory
|
||||||
|
SESSION_DIR=/opt/telegram-web-client/sessions
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
1
backend/auth/__init__.py
Normal file
1
backend/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Auth module
|
||||||
18
backend/config.py
Normal file
18
backend/config.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Telegram API credentials (get from https://my.telegram.org)
|
||||||
|
API_ID = int(os.getenv("TELEGRAM_API_ID", "0"))
|
||||||
|
API_HASH = os.getenv("TELEGRAM_API_HASH", "")
|
||||||
|
|
||||||
|
# Bot username to chat with
|
||||||
|
BOT_USERNAME = os.getenv("BOT_USERNAME", "telegram_summary_bot")
|
||||||
|
|
||||||
|
# Session storage directory
|
||||||
|
SESSION_DIR = os.getenv("SESSION_DIR", "/opt/telegram-web-client/sessions")
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
HOST = os.getenv("HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.getenv("PORT", "8000"))
|
||||||
130
backend/main.py
Normal file
130
backend/main.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from config import HOST, PORT
|
||||||
|
from websocket.handler import manager
|
||||||
|
|
||||||
|
# Frontend dist directory
|
||||||
|
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("Starting Telegram Web Client server...")
|
||||||
|
yield
|
||||||
|
logger.info("Shutting down server...")
|
||||||
|
# Cleanup all connections
|
||||||
|
for session_id in list(manager.active_connections.keys()):
|
||||||
|
await manager.disconnect(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Telegram Web Client",
|
||||||
|
description="WebSocket-based Telegram bot chat client",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS configuration
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # Configure appropriately for production
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api")
|
||||||
|
async def api_root():
|
||||||
|
return {"status": "ok", "message": "Telegram Web Client API"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
"""Main WebSocket endpoint for chat"""
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.connect(websocket, session_id)
|
||||||
|
|
||||||
|
# Send session ID to client
|
||||||
|
await manager.send_message(session_id, {
|
||||||
|
"type": "connected",
|
||||||
|
"data": {"session_id": session_id}
|
||||||
|
})
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_json()
|
||||||
|
await manager.handle_message(session_id, data)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"Client disconnected: {session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket error: {e}")
|
||||||
|
finally:
|
||||||
|
await manager.disconnect(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/{session_id}")
|
||||||
|
async def websocket_with_session(websocket: WebSocket, session_id: str):
|
||||||
|
"""WebSocket endpoint with specific session ID (for reconnection)"""
|
||||||
|
try:
|
||||||
|
await manager.connect(websocket, session_id)
|
||||||
|
|
||||||
|
await manager.send_message(session_id, {
|
||||||
|
"type": "connected",
|
||||||
|
"data": {"session_id": session_id}
|
||||||
|
})
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_json()
|
||||||
|
await manager.handle_message(session_id, data)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"Client disconnected: {session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket error: {e}")
|
||||||
|
finally:
|
||||||
|
await manager.disconnect(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Mount static files if frontend is built
|
||||||
|
if FRONTEND_DIR.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def serve_index():
|
||||||
|
return FileResponse(FRONTEND_DIR / "index.html")
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
# Serve static file if exists, otherwise index.html for SPA
|
||||||
|
file_path = FRONTEND_DIR / full_path
|
||||||
|
if file_path.exists() and file_path.is_file():
|
||||||
|
return FileResponse(file_path)
|
||||||
|
return FileResponse(FRONTEND_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host=HOST, port=PORT)
|
||||||
1
backend/models/__init__.py
Normal file
1
backend/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Models module
|
||||||
42
backend/models/schemas.py
Normal file
42
backend/models/schemas.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneAuthRequest(BaseModel):
|
||||||
|
phone: str
|
||||||
|
|
||||||
|
|
||||||
|
class CodeAuthRequest(BaseModel):
|
||||||
|
phone: str
|
||||||
|
code: str
|
||||||
|
phone_code_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordAuthRequest(BaseModel):
|
||||||
|
phone: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessageRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
text: Optional[str]
|
||||||
|
date: datetime
|
||||||
|
is_outgoing: bool
|
||||||
|
sender_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatus(BaseModel):
|
||||||
|
authenticated: bool
|
||||||
|
phone: Optional[str] = None
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketMessage(BaseModel):
|
||||||
|
type: str # "auth_phone", "auth_code", "auth_password", "send_message", "get_history"
|
||||||
|
data: dict
|
||||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
telethon>=1.42.0
|
||||||
|
fastapi>=0.128.0
|
||||||
|
uvicorn>=0.40.0
|
||||||
|
websockets>=16.0
|
||||||
|
pydantic>=2.12.0
|
||||||
|
python-dotenv>=1.2.0
|
||||||
1
backend/telegram/__init__.py
Normal file
1
backend/telegram/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Telegram module
|
||||||
77
backend/telegram/bot_chat.py
Normal file
77
backend/telegram/bot_chat.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from typing import Optional, Callable, List
|
||||||
|
from telethon import events
|
||||||
|
from telethon.tl.types import Message
|
||||||
|
|
||||||
|
from config import BOT_USERNAME
|
||||||
|
from telegram.client import TelegramClientWrapper
|
||||||
|
from models.schemas import MessageResponse
|
||||||
|
|
||||||
|
|
||||||
|
class BotChatManager:
|
||||||
|
def __init__(self, client_wrapper: TelegramClientWrapper):
|
||||||
|
self.client_wrapper = client_wrapper
|
||||||
|
self.bot_entity = None
|
||||||
|
self._message_callback: Optional[Callable] = None
|
||||||
|
|
||||||
|
async def init_bot_chat(self):
|
||||||
|
"""Initialize chat with the bot"""
|
||||||
|
client = self.client_wrapper.client
|
||||||
|
if not client:
|
||||||
|
raise RuntimeError("Client not initialized")
|
||||||
|
|
||||||
|
# Resolve bot entity
|
||||||
|
self.bot_entity = await client.get_entity(BOT_USERNAME)
|
||||||
|
|
||||||
|
# Set up event handler for all messages in bot chat (incoming and outgoing)
|
||||||
|
@client.on(events.NewMessage(chats=self.bot_entity))
|
||||||
|
async def handle_bot_message(event: events.NewMessage):
|
||||||
|
if self._message_callback:
|
||||||
|
msg = event.message
|
||||||
|
response = MessageResponse(
|
||||||
|
id=msg.id,
|
||||||
|
text=msg.text,
|
||||||
|
date=msg.date,
|
||||||
|
is_outgoing=msg.out,
|
||||||
|
sender_name="You" if msg.out else BOT_USERNAME
|
||||||
|
)
|
||||||
|
await self._message_callback(response)
|
||||||
|
|
||||||
|
return self.bot_entity
|
||||||
|
|
||||||
|
def set_message_callback(self, callback: Callable):
|
||||||
|
"""Set callback for incoming messages"""
|
||||||
|
self._message_callback = callback
|
||||||
|
|
||||||
|
async def send_message(self, text: str) -> MessageResponse:
|
||||||
|
"""Send message to the bot"""
|
||||||
|
client = self.client_wrapper.client
|
||||||
|
if not client or not self.bot_entity:
|
||||||
|
raise RuntimeError("Bot chat not initialized")
|
||||||
|
|
||||||
|
msg = await client.send_message(self.bot_entity, text)
|
||||||
|
return MessageResponse(
|
||||||
|
id=msg.id,
|
||||||
|
text=msg.text,
|
||||||
|
date=msg.date,
|
||||||
|
is_outgoing=True,
|
||||||
|
sender_name="You"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_history(self, limit: int = 50) -> List[MessageResponse]:
|
||||||
|
"""Get chat history with the bot"""
|
||||||
|
client = self.client_wrapper.client
|
||||||
|
if not client or not self.bot_entity:
|
||||||
|
raise RuntimeError("Bot chat not initialized")
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
async for msg in client.iter_messages(self.bot_entity, limit=limit):
|
||||||
|
messages.append(MessageResponse(
|
||||||
|
id=msg.id,
|
||||||
|
text=msg.text,
|
||||||
|
date=msg.date,
|
||||||
|
is_outgoing=msg.out,
|
||||||
|
sender_name="You" if msg.out else BOT_USERNAME
|
||||||
|
))
|
||||||
|
|
||||||
|
# Return in chronological order
|
||||||
|
return list(reversed(messages))
|
||||||
105
backend/telegram/client.py
Normal file
105
backend/telegram/client.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError
|
||||||
|
from telethon.tl.types import User
|
||||||
|
|
||||||
|
from config import API_ID, API_HASH, SESSION_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramClientWrapper:
|
||||||
|
def __init__(self, session_id: str):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.session_file = os.path.join(SESSION_DIR, f"{session_id}.session")
|
||||||
|
self.client: Optional[TelegramClient] = None
|
||||||
|
self.phone: Optional[str] = None
|
||||||
|
self.phone_code_hash: Optional[str] = None
|
||||||
|
self._message_callback: Optional[Callable] = None
|
||||||
|
|
||||||
|
async def init_client(self) -> TelegramClient:
|
||||||
|
"""Initialize or restore Telegram client"""
|
||||||
|
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Use file-based session for persistence
|
||||||
|
self.client = TelegramClient(
|
||||||
|
self.session_file,
|
||||||
|
API_ID,
|
||||||
|
API_HASH,
|
||||||
|
system_version="4.16.30-vxCUSTOM"
|
||||||
|
)
|
||||||
|
await self.client.connect()
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
async def is_authorized(self) -> bool:
|
||||||
|
"""Check if client is authorized"""
|
||||||
|
if not self.client:
|
||||||
|
await self.init_client()
|
||||||
|
return await self.client.is_user_authorized()
|
||||||
|
|
||||||
|
async def send_code(self, phone: str) -> str:
|
||||||
|
"""Send verification code to phone"""
|
||||||
|
if not self.client:
|
||||||
|
await self.init_client()
|
||||||
|
|
||||||
|
self.phone = phone
|
||||||
|
result = await self.client.send_code_request(phone)
|
||||||
|
self.phone_code_hash = result.phone_code_hash
|
||||||
|
return self.phone_code_hash
|
||||||
|
|
||||||
|
async def sign_in_with_code(self, phone: str, code: str, phone_code_hash: str) -> dict:
|
||||||
|
"""Sign in with verification code"""
|
||||||
|
if not self.client:
|
||||||
|
await self.init_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await self.client.sign_in(phone, code, phone_code_hash=phone_code_hash)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"first_name": user.first_name
|
||||||
|
}
|
||||||
|
except SessionPasswordNeededError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"needs_password": True,
|
||||||
|
"message": "Two-factor authentication required"
|
||||||
|
}
|
||||||
|
except PhoneCodeInvalidError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"needs_password": False,
|
||||||
|
"message": "Invalid verification code"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def sign_in_with_password(self, password: str) -> dict:
|
||||||
|
"""Sign in with 2FA password"""
|
||||||
|
if not self.client:
|
||||||
|
await self.init_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await self.client.sign_in(password=password)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"first_name": user.first_name
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_me(self) -> Optional[User]:
|
||||||
|
"""Get current user info"""
|
||||||
|
if not self.client or not await self.is_authorized():
|
||||||
|
return None
|
||||||
|
return await self.client.get_me()
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Disconnect client"""
|
||||||
|
if self.client:
|
||||||
|
await self.client.disconnect()
|
||||||
1
backend/websocket/__init__.py
Normal file
1
backend/websocket/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# WebSocket module
|
||||||
253
backend/websocket/handler.py
Normal file
253
backend/websocket/handler.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from telegram.client import TelegramClientWrapper
|
||||||
|
from telegram.bot_chat import BotChatManager
|
||||||
|
from models.schemas import MessageResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: Dict[str, WebSocket] = {}
|
||||||
|
self.clients: Dict[str, TelegramClientWrapper] = {}
|
||||||
|
self.bot_managers: Dict[str, BotChatManager] = {}
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket, session_id: str):
|
||||||
|
"""Accept WebSocket connection"""
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections[session_id] = websocket
|
||||||
|
|
||||||
|
# Initialize Telegram client
|
||||||
|
client_wrapper = TelegramClientWrapper(session_id)
|
||||||
|
await client_wrapper.init_client()
|
||||||
|
self.clients[session_id] = client_wrapper
|
||||||
|
|
||||||
|
logger.info(f"WebSocket connected: {session_id}")
|
||||||
|
|
||||||
|
async def disconnect(self, session_id: str):
|
||||||
|
"""Handle WebSocket disconnection"""
|
||||||
|
if session_id in self.active_connections:
|
||||||
|
del self.active_connections[session_id]
|
||||||
|
|
||||||
|
if session_id in self.clients:
|
||||||
|
await self.clients[session_id].disconnect()
|
||||||
|
del self.clients[session_id]
|
||||||
|
|
||||||
|
if session_id in self.bot_managers:
|
||||||
|
del self.bot_managers[session_id]
|
||||||
|
|
||||||
|
logger.info(f"WebSocket disconnected: {session_id}")
|
||||||
|
|
||||||
|
async def send_message(self, session_id: str, message: dict):
|
||||||
|
"""Send message to specific connection"""
|
||||||
|
if session_id in self.active_connections:
|
||||||
|
await self.active_connections[session_id].send_json(message)
|
||||||
|
|
||||||
|
async def handle_message(self, session_id: str, data: dict):
|
||||||
|
"""Handle incoming WebSocket message"""
|
||||||
|
msg_type = data.get("type")
|
||||||
|
payload = data.get("data", {})
|
||||||
|
|
||||||
|
client = self.clients.get(session_id)
|
||||||
|
if not client:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "error",
|
||||||
|
"data": {"message": "Client not initialized"}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if msg_type == "check_auth":
|
||||||
|
await self._handle_check_auth(session_id, client)
|
||||||
|
|
||||||
|
elif msg_type == "send_code":
|
||||||
|
await self._handle_send_code(session_id, client, payload)
|
||||||
|
|
||||||
|
elif msg_type == "verify_code":
|
||||||
|
await self._handle_verify_code(session_id, client, payload)
|
||||||
|
|
||||||
|
elif msg_type == "verify_password":
|
||||||
|
await self._handle_verify_password(session_id, client, payload)
|
||||||
|
|
||||||
|
elif msg_type == "send_message":
|
||||||
|
await self._handle_send_message(session_id, payload)
|
||||||
|
|
||||||
|
elif msg_type == "get_history":
|
||||||
|
await self._handle_get_history(session_id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "error",
|
||||||
|
"data": {"message": f"Unknown message type: {msg_type}"}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling message: {e}")
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "error",
|
||||||
|
"data": {"message": str(e)}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_check_auth(self, session_id: str, client: TelegramClientWrapper):
|
||||||
|
"""Check if user is authenticated"""
|
||||||
|
is_auth = await client.is_authorized()
|
||||||
|
|
||||||
|
if is_auth:
|
||||||
|
me = await client.get_me()
|
||||||
|
# Initialize bot chat
|
||||||
|
bot_manager = BotChatManager(client)
|
||||||
|
await bot_manager.init_bot_chat()
|
||||||
|
|
||||||
|
# Set up callback for incoming messages
|
||||||
|
async def on_bot_message(msg: MessageResponse):
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "new_message",
|
||||||
|
"data": msg.model_dump(mode='json')
|
||||||
|
})
|
||||||
|
|
||||||
|
bot_manager.set_message_callback(on_bot_message)
|
||||||
|
self.bot_managers[session_id] = bot_manager
|
||||||
|
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "auth_status",
|
||||||
|
"data": {
|
||||||
|
"authenticated": True,
|
||||||
|
"user_id": me.id,
|
||||||
|
"username": me.username,
|
||||||
|
"first_name": me.first_name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "auth_status",
|
||||||
|
"data": {"authenticated": False}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_send_code(self, session_id: str, client: TelegramClientWrapper, payload: dict):
|
||||||
|
"""Send verification code"""
|
||||||
|
phone = payload.get("phone")
|
||||||
|
if not phone:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "error",
|
||||||
|
"data": {"message": "Phone number required"}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
phone_code_hash = await client.send_code(phone)
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "code_sent",
|
||||||
|
"data": {
|
||||||
|
"phone": phone,
|
||||||
|
"phone_code_hash": phone_code_hash
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_verify_code(self, session_id: str, client: TelegramClientWrapper, payload: dict):
|
||||||
|
"""Verify code and sign in"""
|
||||||
|
phone = payload.get("phone")
|
||||||
|
code = payload.get("code")
|
||||||
|
phone_code_hash = payload.get("phone_code_hash")
|
||||||
|
|
||||||
|
result = await client.sign_in_with_code(phone, code, phone_code_hash)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
# Initialize bot chat after successful auth
|
||||||
|
bot_manager = BotChatManager(client)
|
||||||
|
await bot_manager.init_bot_chat()
|
||||||
|
|
||||||
|
async def on_bot_message(msg: MessageResponse):
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "new_message",
|
||||||
|
"data": msg.model_dump(mode='json')
|
||||||
|
})
|
||||||
|
|
||||||
|
bot_manager.set_message_callback(on_bot_message)
|
||||||
|
self.bot_managers[session_id] = bot_manager
|
||||||
|
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "auth_success",
|
||||||
|
"data": result
|
||||||
|
})
|
||||||
|
elif result.get("needs_password"):
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "need_password",
|
||||||
|
"data": {"message": result.get("message")}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "auth_error",
|
||||||
|
"data": {"message": result.get("message")}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_verify_password(self, session_id: str, client: TelegramClientWrapper, payload: dict):
|
||||||
|
"""Verify 2FA password"""
|
||||||
|
password = payload.get("password")
|
||||||
|
result = await client.sign_in_with_password(password)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
# Initialize bot chat after successful auth
|
||||||
|
bot_manager = BotChatManager(client)
|
||||||
|
await bot_manager.init_bot_chat()
|
||||||
|
|
||||||
|
async def on_bot_message(msg: MessageResponse):
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "new_message",
|
||||||
|
"data": msg.model_dump(mode='json')
|
||||||
|
})
|
||||||
|
|
||||||
|
bot_manager.set_message_callback(on_bot_message)
|
||||||
|
self.bot_managers[session_id] = bot_manager
|
||||||
|
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "auth_success",
|
||||||
|
"data": result
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "auth_error",
|
||||||
|
"data": {"message": result.get("message")}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_send_message(self, session_id: str, payload: dict):
|
||||||
|
"""Send message to bot"""
|
||||||
|
bot_manager = self.bot_managers.get(session_id)
|
||||||
|
if not bot_manager:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "error",
|
||||||
|
"data": {"message": "Not authenticated or bot chat not initialized"}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
text = payload.get("text")
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = await bot_manager.send_message(text)
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "message_sent",
|
||||||
|
"data": msg.model_dump(mode='json')
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_get_history(self, session_id: str):
|
||||||
|
"""Get chat history"""
|
||||||
|
bot_manager = self.bot_managers.get(session_id)
|
||||||
|
if not bot_manager:
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "error",
|
||||||
|
"data": {"message": "Not authenticated"}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
messages = await bot_manager.get_history()
|
||||||
|
await self.send_message(session_id, {
|
||||||
|
"type": "history",
|
||||||
|
"data": {"messages": [m.model_dump(mode='json') for m in messages]}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
102
docs/plans/2026-02-05-telegram-web-client-design.md
Normal file
102
docs/plans/2026-02-05-telegram-web-client-design.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Telegram Web Client Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
웹소켓을 이용해 Telegram 봇(telegram-bot-workers)과 대화하는 웹 앱
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Infrastructure**: Incus 컨테이너 (Debian 13) on jp1/kr1
|
||||||
|
- **Backend**: Python + Telethon v2 + FastAPI WebSocket
|
||||||
|
- **Frontend**: React + TypeScript + Vite
|
||||||
|
- **State Management**: Zustand
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ WebSocket ┌─────────────────────────────┐
|
||||||
|
│ React Frontend │◄──────────────────►│ FastAPI Backend (Debian) │
|
||||||
|
│ (브라우저) │ │ │
|
||||||
|
└─────────────────┘ │ ┌─────────────────────┐ │
|
||||||
|
│ │ Telethon v2 Client │ │
|
||||||
|
│ │ (MTProto) │ │
|
||||||
|
│ └──────────┬──────────┘ │
|
||||||
|
└─────────────┼───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Telegram API (MTProto) │
|
||||||
|
│ ↔ telegram-bot-workers 봇 │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── main.py # FastAPI 앱 진입점
|
||||||
|
├── config.py # 설정 (API_ID, API_HASH, BOT_USERNAME)
|
||||||
|
├── auth/
|
||||||
|
│ ├── telegram_auth.py # Telethon 인증 로직
|
||||||
|
│ └── session_manager.py # 세션 저장/복원
|
||||||
|
├── websocket/
|
||||||
|
│ ├── handler.py # WebSocket 연결 관리
|
||||||
|
│ └── events.py # 메시지 송수신 이벤트
|
||||||
|
├── telegram/
|
||||||
|
│ ├── client.py # Telethon 클라이언트 래퍼
|
||||||
|
│ └── bot_chat.py # 봇과의 대화 전용 로직
|
||||||
|
└── models/
|
||||||
|
└── schemas.py # Pydantic 모델
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── main.tsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Auth/
|
||||||
|
│ │ │ ├── PhoneInput.tsx # 전화번호 입력
|
||||||
|
│ │ │ └── CodeInput.tsx # 인증 코드 입력
|
||||||
|
│ │ └── Chat/
|
||||||
|
│ │ ├── ChatContainer.tsx # 채팅 메인
|
||||||
|
│ │ ├── MessageList.tsx # 메시지 목록
|
||||||
|
│ │ └── MessageInput.tsx # 메시지 입력
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ └── useWebSocket.ts # WebSocket 연결 훅
|
||||||
|
│ ├── stores/
|
||||||
|
│ │ └── chatStore.ts # Zustand 상태 관리
|
||||||
|
│ └── types/
|
||||||
|
│ └── index.ts
|
||||||
|
├── package.json
|
||||||
|
└── vite.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Authentication**: Telegram 로그인 (전화번호 + 인증 코드)
|
||||||
|
- **Session Management**: 로그인 후 세션 파일 저장, 재접속 시 자동 복원
|
||||||
|
- **Real-time Chat**: WebSocket으로 실시간 메시지 송수신
|
||||||
|
- **Bot Filter**: 특정 봇(telegram-bot-workers)과의 대화만 처리
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
1. 사용자가 웹에서 전화번호 입력
|
||||||
|
2. 백엔드가 Telethon으로 Telegram에 인증 코드 요청
|
||||||
|
3. 사용자가 코드 입력 → 세션 생성
|
||||||
|
4. WebSocket 연결 유지, 봇과 실시간 대화
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
환경 변수:
|
||||||
|
- `TELEGRAM_API_ID`: Telegram API ID
|
||||||
|
- `TELEGRAM_API_HASH`: Telegram API Hash
|
||||||
|
- `BOT_USERNAME`: 대화할 봇 username (예: @telegram_summary_bot)
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Incus 컨테이너: Debian 13
|
||||||
|
- systemd 서비스로 백엔드 실행
|
||||||
|
- Caddy/Nginx로 리버스 프록시 + SSL
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Telegram Bot Chat</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "telegram-web-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"zustand": "^5.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.5",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"typescript": "^5.8.4",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
323
frontend/src/App.css
Normal file
323
frontend/src/App.css
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0e1621;
|
||||||
|
--bg-secondary: #17212b;
|
||||||
|
--bg-message-out: #2b5278;
|
||||||
|
--bg-message-in: #182533;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #8b9399;
|
||||||
|
--accent: #5288c1;
|
||||||
|
--accent-hover: #6ba0d5;
|
||||||
|
--error: #e53935;
|
||||||
|
--border: #0e1621;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.connecting {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--bg-secondary);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth Container */
|
||||||
|
.auth-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container input {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container button {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container button:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container button.secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container button.secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-message-in);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Container */
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.online::before {
|
||||||
|
background: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.offline::before {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message List */
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.outgoing {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.incoming {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.outgoing .message-content {
|
||||||
|
background: var(--bg-message-out);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.incoming .message-content {
|
||||||
|
background: var(--bg-message-in);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.outgoing .sender {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Input */
|
||||||
|
.message-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: none;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input textarea::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input button:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.chat-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
frontend/src/App.tsx
Normal file
37
frontend/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useChatStore } from './stores/chatStore';
|
||||||
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
|
import { PhoneInput } from './components/Auth/PhoneInput';
|
||||||
|
import { CodeInput } from './components/Auth/CodeInput';
|
||||||
|
import { PasswordInput } from './components/Auth/PasswordInput';
|
||||||
|
import { ChatContainer } from './components/Chat/ChatContainer';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { authStep, connected, setAuthStep, setError } = useChatStore();
|
||||||
|
const { sendCode, verifyCode, verifyPassword, sendMessage } = useWebSocket();
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setError(null);
|
||||||
|
setAuthStep('phone');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
return (
|
||||||
|
<div className="app connecting">
|
||||||
|
<div className="loader" />
|
||||||
|
<p>Connecting to server...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{authStep === 'phone' && <PhoneInput onSubmit={sendCode} />}
|
||||||
|
{authStep === 'code' && <CodeInput onSubmit={verifyCode} onBack={handleBack} />}
|
||||||
|
{authStep === 'password' && <PasswordInput onSubmit={verifyPassword} />}
|
||||||
|
{authStep === 'authenticated' && <ChatContainer onSendMessage={sendMessage} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
48
frontend/src/components/Auth/CodeInput.tsx
Normal file
48
frontend/src/components/Auth/CodeInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (code: string) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeInput({ onSubmit, onBack }: Props) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const { phone, loading, error } = useChatStore();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (code.trim()) {
|
||||||
|
onSubmit(code.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<h2>Verification Code</h2>
|
||||||
|
<p>
|
||||||
|
Enter the code sent to <strong>{phone}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="12345"
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading || !code.trim()}>
|
||||||
|
{loading ? 'Verifying...' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onBack} disabled={loading} className="secondary">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/Auth/PasswordInput.tsx
Normal file
41
frontend/src/components/Auth/PasswordInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (password: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordInput({ onSubmit }: Props) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const { loading, error } = useChatStore();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password) {
|
||||||
|
onSubmit(password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<h2>Two-Factor Authentication</h2>
|
||||||
|
<p>Enter your 2FA password</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Password"
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading || !password}>
|
||||||
|
{loading ? 'Verifying...' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/Auth/PhoneInput.tsx
Normal file
41
frontend/src/components/Auth/PhoneInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (phone: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhoneInput({ onSubmit }: Props) {
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const { loading, error } = useChatStore();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (phone.trim()) {
|
||||||
|
onSubmit(phone.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<h2>Telegram Login</h2>
|
||||||
|
<p>Enter your phone number to start chatting with the bot</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+82 10 1234 5678"
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading || !phone.trim()}>
|
||||||
|
{loading ? 'Sending...' : 'Send Code'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/Chat/ChatContainer.tsx
Normal file
32
frontend/src/components/Chat/ChatContainer.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
|
import { MessageList } from './MessageList';
|
||||||
|
import { MessageInput } from './MessageInput';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSendMessage: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatContainer({ onSendMessage }: Props) {
|
||||||
|
const { user, connected } = useChatStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-container">
|
||||||
|
<header className="chat-header">
|
||||||
|
<h1>Telegram Bot Chat</h1>
|
||||||
|
<div className="user-info">
|
||||||
|
<span className={`status ${connected ? 'online' : 'offline'}`}>
|
||||||
|
{connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
{user && (
|
||||||
|
<span className="username">
|
||||||
|
{user.firstName || user.username || `User ${user.id}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<MessageList />
|
||||||
|
<MessageInput onSend={onSendMessage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/Chat/MessageInput.tsx
Normal file
38
frontend/src/components/Chat/MessageInput.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageInput({ onSend }: Props) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (text.trim()) {
|
||||||
|
onSend(text.trim());
|
||||||
|
setText('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-input">
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSend} disabled={!text.trim()}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/Chat/MessageList.tsx
Normal file
43
frontend/src/components/Chat/MessageList.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
|
|
||||||
|
export function MessageList() {
|
||||||
|
const { messages } = useChatStore();
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-list">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No messages yet. Start chatting!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`message ${msg.is_outgoing ? 'outgoing' : 'incoming'}`}
|
||||||
|
>
|
||||||
|
<div className="message-content">
|
||||||
|
<span className="sender">{msg.sender_name}</span>
|
||||||
|
<p className="text">{msg.text}</p>
|
||||||
|
<span className="time">{formatTime(msg.date)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
frontend/src/hooks/useWebSocket.ts
Normal file
201
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useChatStore } from '../stores/chatStore';
|
||||||
|
import type { Message } from '../types';
|
||||||
|
|
||||||
|
const WS_URL = import.meta.env.DEV
|
||||||
|
? 'ws://localhost:8000/ws'
|
||||||
|
: `wss://${window.location.host}/ws`;
|
||||||
|
|
||||||
|
export function useWebSocket() {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setSessionId,
|
||||||
|
setConnected,
|
||||||
|
setAuthStep,
|
||||||
|
setPhone,
|
||||||
|
setPhoneCodeHash,
|
||||||
|
setUser,
|
||||||
|
addMessage,
|
||||||
|
setMessages,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
} = useChatStore();
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
setConnected(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
setConnected(false);
|
||||||
|
|
||||||
|
// Reconnect after 3 seconds
|
||||||
|
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
setError('Connection error');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
handleMessage(message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMessage = useCallback(
|
||||||
|
(message: { type: string; data: Record<string, unknown> }) => {
|
||||||
|
const { type, data } = message;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'connected':
|
||||||
|
setSessionId(data.session_id as string);
|
||||||
|
// Check auth status
|
||||||
|
send({ type: 'check_auth', data: {} });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'auth_status':
|
||||||
|
if (data.authenticated) {
|
||||||
|
setUser({
|
||||||
|
id: data.user_id as number,
|
||||||
|
username: data.username as string,
|
||||||
|
firstName: data.first_name as string,
|
||||||
|
});
|
||||||
|
setAuthStep('authenticated');
|
||||||
|
// Get chat history
|
||||||
|
send({ type: 'get_history', data: {} });
|
||||||
|
} else {
|
||||||
|
setAuthStep('phone');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'code_sent':
|
||||||
|
setPhone(data.phone as string);
|
||||||
|
setPhoneCodeHash(data.phone_code_hash as string);
|
||||||
|
setAuthStep('code');
|
||||||
|
setLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'need_password':
|
||||||
|
setAuthStep('password');
|
||||||
|
setLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'auth_success':
|
||||||
|
setUser({
|
||||||
|
id: data.user_id as number,
|
||||||
|
username: data.username as string,
|
||||||
|
firstName: data.first_name as string,
|
||||||
|
});
|
||||||
|
setAuthStep('authenticated');
|
||||||
|
setLoading(false);
|
||||||
|
// Get chat history
|
||||||
|
send({ type: 'get_history', data: {} });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'auth_error':
|
||||||
|
setError(data.message as string);
|
||||||
|
setLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'history':
|
||||||
|
setMessages((data.messages as Message[]) || []);
|
||||||
|
setLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_sent':
|
||||||
|
addMessage(data as unknown as Message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'new_message':
|
||||||
|
addMessage(data as unknown as Message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
setError(data.message as string);
|
||||||
|
setLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('Unknown message type:', type);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const send = useCallback((message: { type: string; data: Record<string, unknown> }) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendCode = useCallback((phone: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
send({ type: 'send_code', data: { phone } });
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const verifyCode = useCallback(
|
||||||
|
(code: string) => {
|
||||||
|
const { phone, phoneCodeHash } = useChatStore.getState();
|
||||||
|
setLoading(true);
|
||||||
|
send({
|
||||||
|
type: 'verify_code',
|
||||||
|
data: { phone, code, phone_code_hash: phoneCodeHash },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[send]
|
||||||
|
);
|
||||||
|
|
||||||
|
const verifyPassword = useCallback(
|
||||||
|
(password: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
send({ type: 'verify_password', data: { password } });
|
||||||
|
},
|
||||||
|
[send]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
send({ type: 'send_message', data: { text } });
|
||||||
|
},
|
||||||
|
[send]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendCode,
|
||||||
|
verifyCode,
|
||||||
|
verifyPassword,
|
||||||
|
sendMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
65
frontend/src/stores/chatStore.ts
Normal file
65
frontend/src/stores/chatStore.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Message, AuthStep } from '../types';
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
// Connection
|
||||||
|
sessionId: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
authStep: AuthStep;
|
||||||
|
phone: string;
|
||||||
|
phoneCodeHash: string;
|
||||||
|
user: {
|
||||||
|
id?: number;
|
||||||
|
username?: string;
|
||||||
|
firstName?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
messages: Message[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSessionId: (id: string) => void;
|
||||||
|
setConnected: (connected: boolean) => void;
|
||||||
|
setAuthStep: (step: AuthStep) => void;
|
||||||
|
setPhone: (phone: string) => void;
|
||||||
|
setPhoneCodeHash: (hash: string) => void;
|
||||||
|
setUser: (user: ChatState['user']) => void;
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
setMessages: (messages: Message[]) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
sessionId: null,
|
||||||
|
connected: false,
|
||||||
|
authStep: 'phone' as AuthStep,
|
||||||
|
phone: '',
|
||||||
|
phoneCodeHash: '',
|
||||||
|
user: null,
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useChatStore = create<ChatState>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setSessionId: (id) => set({ sessionId: id }),
|
||||||
|
setConnected: (connected) => set({ connected }),
|
||||||
|
setAuthStep: (step) => set({ authStep: step }),
|
||||||
|
setPhone: (phone) => set({ phone }),
|
||||||
|
setPhoneCodeHash: (hash) => set({ phoneCodeHash: hash }),
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
addMessage: (message) =>
|
||||||
|
set((state) => ({ messages: [...state.messages, message] })),
|
||||||
|
setMessages: (messages) => set({ messages }),
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
21
frontend/src/types/index.ts
Normal file
21
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface Message {
|
||||||
|
id: number;
|
||||||
|
text: string | null;
|
||||||
|
date: string;
|
||||||
|
is_outgoing: boolean;
|
||||||
|
sender_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
authenticated: boolean;
|
||||||
|
user_id?: number;
|
||||||
|
username?: string;
|
||||||
|
first_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketMessage {
|
||||||
|
type: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthStep = 'phone' | 'code' | 'password' | 'authenticated';
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
15
telegram-web-client.service
Normal file
15
telegram-web-client.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Telegram Web Client Backend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/telegram-web-client/backend
|
||||||
|
Environment=PATH=/opt/telegram-web-client/backend/venv/bin:/usr/bin
|
||||||
|
ExecStart=/opt/telegram-web-client/backend/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user