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>
This commit is contained in:
531
telegram-cli/src/index.ts
Normal file
531
telegram-cli/src/index.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user