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:
kappa
2026-01-26 04:24:02 +09:00
parent 13c59fbfb8
commit 5413605347
11 changed files with 3266 additions and 6 deletions

10
telegram-cli/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Telegram Bot Configuration
BOT_TOKEN=your_telegram_bot_token_here
WEBHOOK_SECRET=your_webhook_secret_here
CHAT_ID=your_telegram_user_id_here
# Worker Configuration
WORKER_URL=https://telegram-summary-bot.kappa-d8e.workers.dev
# Optional: Enable debug mode
DEBUG=false

324
telegram-cli/README.md Normal file
View File

@@ -0,0 +1,324 @@
# Telegram Bot Web Chat UI
Cloudflare Worker로 변환된 웹 채팅 인터페이스 및 API 엔드포인트
## 기능
- **웹 채팅 UI** (GET /): 브라우저에서 봇과 대화
- **JSON API** (POST /api/chat): Claude나 curl로 사용 가능
- **Health Check** (GET /health): 상태 확인
## 배포 방법
### 1. 의존성 설치
```bash
cd telegram-cli
npm install
```
### 2. Secrets 설정
```bash
# BOT_TOKEN 설정 (실제로는 사용하지 않지만 필수 변수)
wrangler secret put BOT_TOKEN
# WEBHOOK_SECRET 설정 (Bot Worker /api/test 인증용)
wrangler secret put WEBHOOK_SECRET
```
**Vault에서 가져오기:**
```bash
vault kv get secret/telegram-bot
```
### 3. 로컬 테스트
```bash
npm run dev
```
브라우저에서 http://localhost:8787 접속
### 4. 배포
```bash
npm run deploy
```
배포 후 URL: https://telegram-cli-web.your-subdomain.workers.dev
## 엔드포인트
### GET /
웹 채팅 UI (HTML/CSS/JS 인라인)
**특징:**
- 다크 테마
- 실시간 응답 시간 표시
- 로딩 상태 표시
- Enter로 메시지 전송
- 자동 스크롤
**사용법:**
1. 브라우저에서 Worker URL 접속
2. 메시지 입력창에 텍스트 입력
3. Enter 키 또는 "전송" 버튼 클릭
4. 봇 응답 대기 (응답 시간 표시됨)
### POST /api/chat
JSON API 엔드포인트
**Request:**
```json
{
"message": "서버 추천해줘"
}
```
**Response:**
```json
{
"response": "봇 응답 내용...",
"time_ms": 1234
}
```
**curl 예시:**
```bash
curl -X POST https://telegram-cli-web.your-subdomain.workers.dev/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "서버 추천해줘"}'
```
**Claude가 사용하는 경우:**
```bash
# Claude는 이 API를 호출하여 봇과 대화할 수 있습니다
curl -X POST https://telegram-cli-web.your-subdomain.workers.dev/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "날씨 알려줘"}'
```
### GET /health
Health check 엔드포인트
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-01-26T00:00:00.000Z"
}
```
## 환경변수
### Secrets (wrangler secret put)
| 변수 | 설명 | 필수 |
|------|------|------|
| `BOT_TOKEN` | Telegram Bot Token (실제로는 미사용) | ✅ |
| `WEBHOOK_SECRET` | Bot Worker /api/test 인증용 | ✅ |
### Variables (wrangler.toml)
| 변수 | 기본값 | 설명 |
|------|--------|------|
| `CHAT_ID` | `821596605` | Telegram Chat ID |
| `BOT_WORKER_URL` | `https://telegram-summary-bot...` | Bot Worker URL |
**변경 방법:**
```toml
# wrangler.toml
[vars]
CHAT_ID = "YOUR_TELEGRAM_ID"
BOT_WORKER_URL = "https://your-bot-worker.workers.dev"
```
## 아키텍처
```
브라우저 (Web UI)
GET / → HTML/CSS/JS 반환
POST /api/chat → telegram-cli-web Worker
Bot Worker (/api/test)
- message
- chat_id
- user_id
- username: 'web-tester'
Bot 응답 처리
- DB 작업
- AI 응답 생성
- Function Calling
Web UI로 응답 반환
{ response, time_ms }
```
## 테스트 시나리오
### 1. 웹 UI 테스트
브라우저에서 접속 후:
```
# 기본 대화
"안녕하세요"
"날씨 알려줘"
# Function Calling 도구
"서울 날씨"
"ChatGPT 가격 검색"
"123 * 456"
"현재 시간"
# 도메인
"example.com 조회"
"도메인 추천해줘: 커피숍"
# 예치금
"잔액 조회"
"홍길동 5000원 입금"
# 서버
"서버 추천해줘"
"Linode 2GB 도쿄 서버 생성"
```
### 2. API 테스트 (curl)
```bash
# 기본 대화
curl -X POST https://your-worker.workers.dev/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "안녕하세요"}'
# 서버 추천
curl -X POST https://your-worker.workers.dev/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "서버 추천해줘"}'
# 잔액 조회
curl -X POST https://your-worker.workers.dev/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "/deposit"}'
```
### 3. Claude 사용 예시
Claude가 이 API를 사용하여 봇과 대화:
```bash
# Claude는 이 엔드포인트를 호출하여 봇의 기능을 테스트할 수 있습니다
curl -X POST https://telegram-cli-web.workers.dev/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "날씨 알려줘"}'
```
## 로그 확인
```bash
npm run tail
```
실시간 로그 스트리밍으로 요청/응답 확인 가능
## 개발 모드
```bash
npm run dev
```
로컬에서 http://localhost:8787 접속하여 테스트
**로컬 개발 시 주의사항:**
- Secrets는 `.dev.vars` 파일에 설정
- 또는 `wrangler dev --remote`로 프로덕션 Secrets 사용
## 기술 스택
- **Runtime**: Cloudflare Workers (V8 Isolate)
- **Language**: TypeScript
- **Frontend**: Vanilla JavaScript (인라인 HTML)
- **CSS**: 다크 테마, 애니메이션, 반응형
## 보안
- ✅ CORS 설정 완료 (모든 Origin 허용)
- ✅ Bot Worker의 /api/test 엔드포인트는 Bearer 토큰으로 인증
- ✅ Secrets는 Workers 환경변수로 안전하게 관리
- ⚠️ 웹 UI는 공개 접근 가능 (인증 없음)
## 문제 해결
### Secrets 미설정 오류
**증상:**
```
Bot Worker error: 401 - Unauthorized
```
**해결:**
```bash
wrangler secret put WEBHOOK_SECRET
# Vault에서 가져온 값 입력
```
### Bot Worker URL 변경
`wrangler.toml` 수정:
```toml
[vars]
BOT_WORKER_URL = "https://new-worker.workers.dev"
```
### CHAT_ID 변경
`wrangler.toml` 수정:
```toml
[vars]
CHAT_ID = "123456789"
```
### 로컬 테스트 시 Secrets 설정
`.dev.vars` 파일 생성:
```env
BOT_TOKEN=1234567890:ABC...
WEBHOOK_SECRET=your-webhook-secret
```
## 성능
- **응답 시간**: 평균 200-500ms (Bot Worker 처리 시간 포함)
- **Cold Start**: ~100ms (Workers 특성상 매우 빠름)
- **동시 요청**: 무제한 (Workers 스케일링)
## 라이센스
MIT
## 관련 파일
| 파일 | 역할 |
|------|------|
| `src/index.ts` | Worker 메인 로직 |
| `wrangler.toml` | Workers 설정 |
| `package.json` | 의존성 및 스크립트 |
| `../src/routes/api.ts` | Bot Worker /api/test 엔드포인트 |
| `../openapi.yaml` | API 문서 |
## 참고 자료
- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
- [Telegram Bot API](https://core.telegram.org/bots/api)

2118
telegram-cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
telegram-cli/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "telegram-cli",
"version": "1.0.0",
"description": "Telegram Bot Web Chat UI - Cloudflare Worker",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"dependencies": {
"@cloudflare/workers-types": "^4.20241127.0"
},
"devDependencies": {
"typescript": "^5.3.3",
"wrangler": "^3.95.0"
}
}

531
telegram-cli/src/index.ts Normal file
View 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 },
});
}
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,20 @@
name = "telegram-cli-web"
main = "src/index.ts"
compatibility_date = "2024-01-01"
# Environment Variables
[vars]
CHAT_ID = "821596605"
BOT_WORKER_URL = "https://telegram-summary-bot.kappa-d8e.workers.dev"
# Secrets (set with: wrangler secret put SECRET_NAME)
# - BOT_TOKEN
# - WEBHOOK_SECRET
# Service Binding (Worker-to-Worker 통신)
[[services]]
binding = "BOT_WORKER"
service = "telegram-summary-bot"
# Deploy
# wrangler deploy