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

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