Files
telegram-bot-workers/telegram-cli/src/index.ts
kappa 5413605347 feat: add telegram-cli web chat interface and /api/chat endpoint
- 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>
2026-01-26 04:24:02 +09:00

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