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