- Add telegram-cli Worker with web chat UI for browser-based bot testing - Add POST /api/chat authenticated endpoint (Bearer token, production enabled) - Fix ENVIRONMENT to production in wrangler.toml (was blocking Service Binding) - Add Service Binding (BOT_WORKER) for Worker-to-Worker communication - Add cloud-db-schema.sql for local development telegram-cli features: - Web UI at GET / with dark theme - JSON API at POST /api/chat - Service Binding to telegram-summary-bot Worker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
532 lines
13 KiB
TypeScript
532 lines
13 KiB
TypeScript
/**
|
|
* Telegram Bot Web Chat UI - Cloudflare Worker
|
|
*
|
|
* Endpoints:
|
|
* - GET / : Web chat UI
|
|
* - POST /api/chat : JSON API
|
|
* - GET /health : Health check
|
|
*/
|
|
|
|
interface Env {
|
|
BOT_TOKEN: string;
|
|
WEBHOOK_SECRET: string;
|
|
CHAT_ID: string;
|
|
BOT_WORKER_URL: string;
|
|
BOT_WORKER?: Fetcher; // Service Binding
|
|
}
|
|
|
|
interface ChatRequest {
|
|
message: string;
|
|
}
|
|
|
|
interface ChatResponse {
|
|
response: string;
|
|
time_ms: number;
|
|
}
|
|
|
|
/**
|
|
* Main Worker fetch handler
|
|
*/
|
|
export default {
|
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
|
|
// CORS headers for API
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
};
|
|
|
|
// Handle CORS preflight
|
|
if (request.method === 'OPTIONS') {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
// Route handling
|
|
if (url.pathname === '/' && request.method === 'GET') {
|
|
return handleWebUI(env);
|
|
}
|
|
|
|
if (url.pathname === '/api/chat' && request.method === 'POST') {
|
|
return await handleChatAPI(request, env, corsHeaders);
|
|
}
|
|
|
|
if (url.pathname === '/health' && request.method === 'GET') {
|
|
return new Response(JSON.stringify({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString()
|
|
}), {
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
});
|
|
}
|
|
|
|
return new Response('Not Found', { status: 404 });
|
|
} catch (error) {
|
|
console.error('[Worker] Error:', error);
|
|
return new Response(JSON.stringify({
|
|
error: 'Internal Server Error',
|
|
message: error instanceof Error ? error.message : String(error)
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Handle Web UI (GET /)
|
|
*/
|
|
function handleWebUI(env: Env): Response {
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Telegram Bot Chat</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: #0f0f0f;
|
|
color: #e0e0e0;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.header {
|
|
background: #1a1a1a;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid #333;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.header .info {
|
|
font-size: 0.875rem;
|
|
color: #888;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.chat-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.message {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-width: 75%;
|
|
animation: fadeIn 0.3s ease-in;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.message.user {
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.message.bot {
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.message-content {
|
|
padding: 0.875rem 1.125rem;
|
|
border-radius: 1rem;
|
|
word-wrap: break-word;
|
|
white-space: pre-wrap;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.message.user .message-content {
|
|
background: #0084ff;
|
|
color: #fff;
|
|
border-bottom-right-radius: 0.25rem;
|
|
}
|
|
|
|
.message.bot .message-content {
|
|
background: #2a2a2a;
|
|
color: #e0e0e0;
|
|
border-bottom-left-radius: 0.25rem;
|
|
border: 1px solid #333;
|
|
}
|
|
|
|
.message-meta {
|
|
font-size: 0.75rem;
|
|
color: #666;
|
|
margin-top: 0.375rem;
|
|
padding: 0 0.5rem;
|
|
}
|
|
|
|
.message.user .message-meta {
|
|
text-align: right;
|
|
}
|
|
|
|
.input-container {
|
|
background: #1a1a1a;
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid #333;
|
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.input-wrapper {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
}
|
|
|
|
#messageInput {
|
|
flex: 1;
|
|
padding: 0.875rem 1.125rem;
|
|
border: 1px solid #333;
|
|
border-radius: 1.5rem;
|
|
background: #0f0f0f;
|
|
color: #e0e0e0;
|
|
font-size: 0.9375rem;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
#messageInput:focus {
|
|
border-color: #0084ff;
|
|
}
|
|
|
|
#sendButton {
|
|
padding: 0.875rem 1.75rem;
|
|
background: #0084ff;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 1.5rem;
|
|
cursor: pointer;
|
|
font-size: 0.9375rem;
|
|
font-weight: 600;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#sendButton:hover:not(:disabled) {
|
|
background: #006cd9;
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(0, 132, 255, 0.3);
|
|
}
|
|
|
|
#sendButton:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: #888;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.loading-dots {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.loading-dots span {
|
|
width: 6px;
|
|
height: 6px;
|
|
background: #888;
|
|
border-radius: 50%;
|
|
animation: bounce 1.4s infinite ease-in-out both;
|
|
}
|
|
|
|
.loading-dots span:nth-child(1) { animation-delay: -0.32s; }
|
|
.loading-dots span:nth-child(2) { animation-delay: -0.16s; }
|
|
|
|
@keyframes bounce {
|
|
0%, 80%, 100% { transform: scale(0); }
|
|
40% { transform: scale(1); }
|
|
}
|
|
|
|
.error {
|
|
background: #ff4444;
|
|
color: #fff;
|
|
padding: 0.875rem 1.125rem;
|
|
border-radius: 0.5rem;
|
|
margin: 1rem 0;
|
|
animation: fadeIn 0.3s ease-in;
|
|
}
|
|
|
|
/* Scrollbar styling */
|
|
.chat-container::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
.chat-container::-webkit-scrollbar-track {
|
|
background: #0f0f0f;
|
|
}
|
|
|
|
.chat-container::-webkit-scrollbar-thumb {
|
|
background: #333;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.chat-container::-webkit-scrollbar-thumb:hover {
|
|
background: #444;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🤖 Telegram Bot Chat</h1>
|
|
<div class="info">User ID: ${env.CHAT_ID}</div>
|
|
</div>
|
|
|
|
<div class="chat-container" id="chatContainer"></div>
|
|
|
|
<div class="input-container">
|
|
<div class="input-wrapper">
|
|
<input
|
|
type="text"
|
|
id="messageInput"
|
|
placeholder="메시지를 입력하세요..."
|
|
autocomplete="off"
|
|
/>
|
|
<button id="sendButton">전송</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const chatContainer = document.getElementById('chatContainer');
|
|
const messageInput = document.getElementById('messageInput');
|
|
const sendButton = document.getElementById('sendButton');
|
|
let isLoading = false;
|
|
|
|
// Add message to chat
|
|
function addMessage(text, type, meta = '') {
|
|
const messageDiv = document.createElement('div');
|
|
messageDiv.className = \`message \${type}\`;
|
|
|
|
const contentDiv = document.createElement('div');
|
|
contentDiv.className = 'message-content';
|
|
contentDiv.textContent = text;
|
|
|
|
const metaDiv = document.createElement('div');
|
|
metaDiv.className = 'message-meta';
|
|
metaDiv.textContent = meta;
|
|
|
|
messageDiv.appendChild(contentDiv);
|
|
if (meta) messageDiv.appendChild(metaDiv);
|
|
|
|
chatContainer.appendChild(messageDiv);
|
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
}
|
|
|
|
// Show loading indicator
|
|
function showLoading() {
|
|
const loadingDiv = document.createElement('div');
|
|
loadingDiv.className = 'message bot';
|
|
loadingDiv.id = 'loadingIndicator';
|
|
loadingDiv.innerHTML = \`
|
|
<div class="message-content">
|
|
<div class="loading">
|
|
<div class="loading-dots">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</div>
|
|
처리 중...
|
|
</div>
|
|
</div>
|
|
\`;
|
|
chatContainer.appendChild(loadingDiv);
|
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
}
|
|
|
|
// Remove loading indicator
|
|
function removeLoading() {
|
|
const loading = document.getElementById('loadingIndicator');
|
|
if (loading) loading.remove();
|
|
}
|
|
|
|
// Show error
|
|
function showError(message) {
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'error';
|
|
errorDiv.textContent = '❌ ' + message;
|
|
chatContainer.appendChild(errorDiv);
|
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
setTimeout(() => errorDiv.remove(), 5000);
|
|
}
|
|
|
|
// Send message
|
|
async function sendMessage() {
|
|
const text = messageInput.value.trim();
|
|
if (!text || isLoading) return;
|
|
|
|
// Add user message
|
|
addMessage(text, 'user', new Date().toLocaleTimeString('ko-KR'));
|
|
messageInput.value = '';
|
|
|
|
// Set loading state
|
|
isLoading = true;
|
|
sendButton.disabled = true;
|
|
showLoading();
|
|
|
|
try {
|
|
const startTime = Date.now();
|
|
const response = await fetch('/api/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: text }),
|
|
});
|
|
|
|
removeLoading();
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'API 요청 실패');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Add bot response
|
|
addMessage(
|
|
data.response,
|
|
'bot',
|
|
\`\${new Date().toLocaleTimeString('ko-KR')} • \${duration}ms\`
|
|
);
|
|
} catch (error) {
|
|
removeLoading();
|
|
showError(error.message || '메시지 전송 실패');
|
|
} finally {
|
|
isLoading = false;
|
|
sendButton.disabled = false;
|
|
messageInput.focus();
|
|
}
|
|
}
|
|
|
|
// Event listeners
|
|
sendButton.addEventListener('click', sendMessage);
|
|
messageInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
|
|
// Initial focus
|
|
messageInput.focus();
|
|
|
|
// Welcome message
|
|
addMessage(
|
|
'안녕하세요! 메시지를 입력하세요.',
|
|
'bot',
|
|
new Date().toLocaleTimeString('ko-KR')
|
|
);
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
return new Response(html, {
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle Chat API (POST /api/chat)
|
|
*/
|
|
async function handleChatAPI(
|
|
request: Request,
|
|
env: Env,
|
|
corsHeaders: Record<string, string>
|
|
): Promise<Response> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Parse request
|
|
const body = await request.json() as ChatRequest;
|
|
const message = body.message?.trim();
|
|
|
|
if (!message) {
|
|
return new Response(JSON.stringify({
|
|
error: 'Message is required'
|
|
}), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
});
|
|
}
|
|
|
|
// Call bot worker /api/chat endpoint (Service Binding 우선, fallback: URL)
|
|
const requestInit = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${env.WEBHOOK_SECRET}`,
|
|
},
|
|
body: JSON.stringify({
|
|
message,
|
|
chat_id: parseInt(env.CHAT_ID),
|
|
user_id: parseInt(env.CHAT_ID),
|
|
username: 'web-tester',
|
|
}),
|
|
};
|
|
|
|
const response = env.BOT_WORKER
|
|
? await env.BOT_WORKER.fetch('https://internal/api/chat', requestInit)
|
|
: await fetch(`${env.BOT_WORKER_URL}/api/chat`, requestInit);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Bot Worker error: ${response.status} - ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json() as any;
|
|
|
|
if (data.error) {
|
|
throw new Error(data.error);
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
const result: ChatResponse = {
|
|
response: data.response || 'No response from bot',
|
|
time_ms: duration,
|
|
};
|
|
|
|
return new Response(JSON.stringify(result), {
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
});
|
|
} catch (error) {
|
|
console.error('[ChatAPI] Error:', error);
|
|
|
|
return new Response(JSON.stringify({
|
|
error: 'Failed to process message',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
});
|
|
}
|
|
}
|