From e610a45fcf843682bdabaaca4dcdc5c5ea57b669 Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 5 Feb 2026 13:55:22 +0900 Subject: [PATCH] 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 --- .mcp.json | 8 + README.md | 142 ++++++++ backend/.env.example | 13 + backend/auth/__init__.py | 1 + backend/config.py | 18 + backend/main.py | 130 +++++++ backend/models/__init__.py | 1 + backend/models/schemas.py | 42 +++ backend/requirements.txt | 6 + backend/telegram/__init__.py | 1 + backend/telegram/bot_chat.py | 77 +++++ backend/telegram/client.py | 105 ++++++ backend/websocket/__init__.py | 1 + backend/websocket/handler.py | 253 ++++++++++++++ .../2026-02-05-telegram-web-client-design.md | 102 ++++++ frontend/index.html | 13 + frontend/package.json | 23 ++ frontend/src/App.css | 323 ++++++++++++++++++ frontend/src/App.tsx | 37 ++ frontend/src/components/Auth/CodeInput.tsx | 48 +++ .../src/components/Auth/PasswordInput.tsx | 41 +++ frontend/src/components/Auth/PhoneInput.tsx | 41 +++ .../src/components/Chat/ChatContainer.tsx | 32 ++ frontend/src/components/Chat/MessageInput.tsx | 38 +++ frontend/src/components/Chat/MessageList.tsx | 43 +++ frontend/src/hooks/useWebSocket.ts | 201 +++++++++++ frontend/src/main.tsx | 9 + frontend/src/stores/chatStore.ts | 65 ++++ frontend/src/types/index.ts | 21 ++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 21 ++ frontend/tsconfig.node.json | 11 + frontend/vite.config.ts | 15 + telegram-web-client.service | 15 + 34 files changed, 1898 insertions(+) create mode 100644 .mcp.json create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/auth/__init__.py create mode 100644 backend/config.py create mode 100644 backend/main.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/schemas.py create mode 100644 backend/requirements.txt create mode 100644 backend/telegram/__init__.py create mode 100644 backend/telegram/bot_chat.py create mode 100644 backend/telegram/client.py create mode 100644 backend/websocket/__init__.py create mode 100644 backend/websocket/handler.py create mode 100644 docs/plans/2026-02-05-telegram-web-client-design.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Auth/CodeInput.tsx create mode 100644 frontend/src/components/Auth/PasswordInput.tsx create mode 100644 frontend/src/components/Auth/PhoneInput.tsx create mode 100644 frontend/src/components/Chat/ChatContainer.tsx create mode 100644 frontend/src/components/Chat/MessageInput.tsx create mode 100644 frontend/src/components/Chat/MessageList.tsx create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/stores/chatStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 telegram-web-client.service diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8de8409 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "haproxy": { + "type": "streamable-http", + "url": "http://100.108.39.107:8000/mcp" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0388f14 --- /dev/null +++ b/README.md @@ -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) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..66fd6df --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/auth/__init__.py b/backend/auth/__init__.py new file mode 100644 index 0000000..a34ab3e --- /dev/null +++ b/backend/auth/__init__.py @@ -0,0 +1 @@ +# Auth module diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..1957726 --- /dev/null +++ b/backend/config.py @@ -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")) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..4b4db3a --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e2313c5 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1 @@ +# Models module diff --git a/backend/models/schemas.py b/backend/models/schemas.py new file mode 100644 index 0000000..5aad3ee --- /dev/null +++ b/backend/models/schemas.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d7f0607 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/telegram/__init__.py b/backend/telegram/__init__.py new file mode 100644 index 0000000..f168fca --- /dev/null +++ b/backend/telegram/__init__.py @@ -0,0 +1 @@ +# Telegram module diff --git a/backend/telegram/bot_chat.py b/backend/telegram/bot_chat.py new file mode 100644 index 0000000..8009b91 --- /dev/null +++ b/backend/telegram/bot_chat.py @@ -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)) diff --git a/backend/telegram/client.py b/backend/telegram/client.py new file mode 100644 index 0000000..00bceb9 --- /dev/null +++ b/backend/telegram/client.py @@ -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() diff --git a/backend/websocket/__init__.py b/backend/websocket/__init__.py new file mode 100644 index 0000000..a8623be --- /dev/null +++ b/backend/websocket/__init__.py @@ -0,0 +1 @@ +# WebSocket module diff --git a/backend/websocket/handler.py b/backend/websocket/handler.py new file mode 100644 index 0000000..2582af8 --- /dev/null +++ b/backend/websocket/handler.py @@ -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() diff --git a/docs/plans/2026-02-05-telegram-web-client-design.md b/docs/plans/2026-02-05-telegram-web-client-design.md new file mode 100644 index 0000000..92e9b3b --- /dev/null +++ b/docs/plans/2026-02-05-telegram-web-client-design.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..19f7661 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Telegram Bot Chat + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6310588 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..6139803 --- /dev/null +++ b/frontend/src/App.css @@ -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%; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9f37531 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+
+

Connecting to server...

+
+ ); + } + + return ( +
+ {authStep === 'phone' && } + {authStep === 'code' && } + {authStep === 'password' && } + {authStep === 'authenticated' && } +
+ ); +} + +export default App; diff --git a/frontend/src/components/Auth/CodeInput.tsx b/frontend/src/components/Auth/CodeInput.tsx new file mode 100644 index 0000000..4769bd6 --- /dev/null +++ b/frontend/src/components/Auth/CodeInput.tsx @@ -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 ( +
+

Verification Code

+

+ Enter the code sent to {phone} +

+ +
+ setCode(e.target.value)} + placeholder="12345" + disabled={loading} + autoFocus + maxLength={6} + /> + + +
+ + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/Auth/PasswordInput.tsx b/frontend/src/components/Auth/PasswordInput.tsx new file mode 100644 index 0000000..f59d972 --- /dev/null +++ b/frontend/src/components/Auth/PasswordInput.tsx @@ -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 ( +
+

Two-Factor Authentication

+

Enter your 2FA password

+ +
+ setPassword(e.target.value)} + placeholder="Password" + disabled={loading} + autoFocus + /> + +
+ + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/Auth/PhoneInput.tsx b/frontend/src/components/Auth/PhoneInput.tsx new file mode 100644 index 0000000..dfd5360 --- /dev/null +++ b/frontend/src/components/Auth/PhoneInput.tsx @@ -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 ( +
+

Telegram Login

+

Enter your phone number to start chatting with the bot

+ +
+ setPhone(e.target.value)} + placeholder="+82 10 1234 5678" + disabled={loading} + autoFocus + /> + +
+ + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/Chat/ChatContainer.tsx b/frontend/src/components/Chat/ChatContainer.tsx new file mode 100644 index 0000000..9e08bc0 --- /dev/null +++ b/frontend/src/components/Chat/ChatContainer.tsx @@ -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 ( +
+
+

Telegram Bot Chat

+
+ + {connected ? 'Connected' : 'Disconnected'} + + {user && ( + + {user.firstName || user.username || `User ${user.id}`} + + )} +
+
+ + + +
+ ); +} diff --git a/frontend/src/components/Chat/MessageInput.tsx b/frontend/src/components/Chat/MessageInput.tsx new file mode 100644 index 0000000..6254c66 --- /dev/null +++ b/frontend/src/components/Chat/MessageInput.tsx @@ -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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+