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:
kappa
2026-02-05 13:55:22 +09:00
commit e610a45fcf
34 changed files with 1898 additions and 0 deletions

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"haproxy": {
"type": "streamable-http",
"url": "http://100.108.39.107:8000/mcp"
}
}
}

142
README.md Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
# Auth module

18
backend/config.py Normal file
View 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
View 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)

View File

@@ -0,0 +1 @@
# Models module

42
backend/models/schemas.py Normal file
View 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
View 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

View File

@@ -0,0 +1 @@
# Telegram module

View 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
View 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()

View File

@@ -0,0 +1 @@
# WebSocket module

View 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()

View 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
View 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
View 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
View 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
View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);

View 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),
}));

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View 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" }]
}

View 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
View 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,
},
},
},
})

View 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