Initial commit: Telegram bot with Cloudflare Workers

- OpenAI GPT-4o-mini with Function Calling
- Cloudflare D1 for user profiles and message buffer
- Sliding window (3 summaries max) for infinite context
- Tools: weather, search, time, calculator
- Workers AI fallback support
- Webhook security with rate limiting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-14 13:00:44 +09:00
commit 1e71e035e7
15 changed files with 2272 additions and 0 deletions

124
src/commands.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Env } from './types';
import { getConversationContext, getLatestSummary } from './summary-service';
export async function handleCommand(
env: Env,
userId: number,
chatId: string,
command: string,
_args: string
): Promise<string> {
const config = {
threshold: parseInt(env.SUMMARY_THRESHOLD || '20', 10),
maxSummaries: parseInt(env.MAX_SUMMARIES_PER_USER || '3', 10),
};
switch (command) {
case '/start':
return `👋 안녕하세요! AI 어시스턴트입니다.
<b>특징:</b>
• 대화를 통해 당신을 이해합니다
${config.threshold}개 메시지마다 프로필 업데이트
• 무한한 대화 기억 가능
<b>명령어:</b>
/help - 도움말
/context - 현재 컨텍스트 확인
/profile - 내 프로필 보기
/stats - 대화 통계
/reset - 대화 초기화`;
case '/help':
return `📖 <b>도움말</b>
<b>기본 명령어:</b>
/start - 봇 시작
/context - 현재 컨텍스트 상태
/profile - 내 프로필 보기
/stats - 대화 통계
/reset - 모든 대화 기록 삭제
<b>사용자 프로필 시스템:</b>
• 메시지 ${config.threshold}개마다 프로필 업데이트
• 사용자 발언 위주로 관심사/목표/맥락 분석
• 의미 없는 대화(인사, 확인 등)는 제외
일반 메시지를 보내면 AI가 응답합니다.`;
case '/context': {
const ctx = await getConversationContext(env.DB, userId, chatId);
const remaining = config.threshold - ctx.recentMessages.length;
return `📊 <b>현재 컨텍스트</b>
분석된 메시지: ${ctx.previousSummary?.message_count || 0}
버퍼 메시지: ${ctx.recentMessages.length}
프로필 버전: ${ctx.previousSummary?.generation || 0}
총 메시지: ${ctx.totalMessages}
💡 ${remaining > 0 ? `${remaining}개 메시지 후 프로필 업데이트` : '업데이트 대기 중'}`;
}
case '/profile':
case '/summary': {
const summary = await getLatestSummary(env.DB, userId, chatId);
if (!summary) {
return '📭 아직 프로필이 없습니다.\n대화를 더 나눠보세요!';
}
const createdAt = new Date(summary.created_at).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
});
return `👤 <b>내 프로필</b> (v${summary.generation})
${summary.summary}
<i>분석된 메시지: ${summary.message_count}개</i>
<i>업데이트: ${createdAt}</i>`;
}
case '/stats': {
const ctx = await getConversationContext(env.DB, userId, chatId);
const profileCount = await env.DB
.prepare('SELECT COUNT(*) as cnt FROM summaries WHERE user_id = ?')
.bind(userId)
.first<{ cnt: number }>();
return `📈 <b>대화 통계</b>
총 메시지: ${ctx.totalMessages}
프로필 버전: ${ctx.previousSummary?.generation || 0}
저장된 프로필: ${profileCount?.cnt || 0}
버퍼 대기: ${ctx.recentMessages.length}`;
}
case '/reset': {
await env.DB.batch([
env.DB.prepare('DELETE FROM message_buffer WHERE user_id = ?').bind(userId),
env.DB.prepare('DELETE FROM summaries WHERE user_id = ?').bind(userId),
]);
return '🗑️ 모든 대화 기록과 요약이 초기화되었습니다.';
}
case '/debug': {
// 디버그용 명령어 (개발 시 유용)
const ctx = await getConversationContext(env.DB, userId, chatId);
const recentMsgs = ctx.recentMessages.slice(-5).map((m, i) =>
`${i + 1}. [${m.role}] ${m.message.substring(0, 30)}...`
).join('\n');
return `🔧 <b>디버그 정보</b>
User ID: ${userId}
Chat ID: ${chatId}
Buffer Count: ${ctx.recentMessages.length}
Summary Gen: ${ctx.previousSummary?.generation || 0}
<b>최근 버퍼 (5개):</b>
${recentMsgs || '(비어있음)'}`;
}
default:
return '❓ 알 수 없는 명령어입니다.\n/help를 입력해보세요.';
}
}

201
src/index.ts Normal file
View File

@@ -0,0 +1,201 @@
import { Env, TelegramUpdate } from './types';
import { validateWebhookRequest, checkRateLimit } from './security';
import { sendMessage, setWebhook, getWebhookInfo, sendChatAction } from './telegram';
import {
addToBuffer,
processAndSummarize,
generateAIResponse,
} from './summary-service';
import { handleCommand } from './commands';
// 사용자 조회/생성
async function getOrCreateUser(
db: D1Database,
telegramId: string,
firstName: string,
username?: string
): Promise<number> {
const existing = await db
.prepare('SELECT id FROM users WHERE telegram_id = ?')
.bind(telegramId)
.first<{ id: number }>();
if (existing) {
// 마지막 활동 시간 업데이트
await db
.prepare('UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.bind(existing.id)
.run();
return existing.id;
}
// 새 사용자 생성
const result = await db
.prepare('INSERT INTO users (telegram_id, first_name, username) VALUES (?, ?, ?)')
.bind(telegramId, firstName, username || null)
.run();
return result.meta.last_row_id as number;
}
// 메시지 처리
async function handleMessage(
env: Env,
update: TelegramUpdate
): Promise<void> {
if (!update.message?.text) return;
const { message } = update;
const chatId = message.chat.id;
const chatIdStr = chatId.toString();
const text = message.text;
const telegramUserId = message.from.id.toString();
// Rate Limiting 체크
if (!checkRateLimit(telegramUserId)) {
await sendMessage(
env.BOT_TOKEN,
chatId,
'⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.'
);
return;
}
// 사용자 처리
const userId = await getOrCreateUser(
env.DB,
telegramUserId,
message.from.first_name,
message.from.username
);
let responseText: string;
// 명령어 처리
if (text.startsWith('/')) {
const [command, ...argParts] = text.split(' ');
const args = argParts.join(' ');
responseText = await handleCommand(env, userId, chatIdStr, command, args);
} else {
// 타이핑 표시
await sendChatAction(env.BOT_TOKEN, chatId, 'typing');
// 1. 사용자 메시지 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'user', text);
try {
// 2. AI 응답 생성
responseText = await generateAIResponse(env, userId, chatIdStr, text);
// 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
// 4. 임계값 도달시 프로필 업데이트
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
if (summarized) {
responseText += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
}
} catch (error) {
console.error('AI Response error:', error);
responseText = `⚠️ AI 응답 생성 중 오류가 발생했습니다.\n\n<code>${String(error)}</code>`;
}
}
await sendMessage(env.BOT_TOKEN, chatId, responseText);
}
export default {
// HTTP 요청 핸들러
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Webhook 설정 엔드포인트
if (url.pathname === '/setup-webhook') {
if (!env.BOT_TOKEN) {
return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 });
}
if (!env.WEBHOOK_SECRET) {
return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 });
}
const webhookUrl = `${url.origin}/webhook`;
const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET);
return Response.json(result);
}
// Webhook 정보 조회
if (url.pathname === '/webhook-info') {
if (!env.BOT_TOKEN) {
return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 });
}
const result = await getWebhookInfo(env.BOT_TOKEN);
return Response.json(result);
}
// 헬스 체크
if (url.pathname === '/health') {
try {
const userCount = await env.DB
.prepare('SELECT COUNT(*) as cnt FROM users')
.first<{ cnt: number }>();
const summaryCount = await env.DB
.prepare('SELECT COUNT(*) as cnt FROM summaries')
.first<{ cnt: number }>();
return Response.json({
status: 'ok',
timestamp: new Date().toISOString(),
stats: {
users: userCount?.cnt || 0,
summaries: summaryCount?.cnt || 0,
},
});
} catch (error) {
return Response.json({
status: 'error',
error: String(error),
}, { status: 500 });
}
}
// Telegram Webhook 처리
if (url.pathname === '/webhook') {
// 보안 검증
const validation = await validateWebhookRequest(request, env);
if (!validation.valid) {
console.error('Webhook validation failed:', validation.error);
return new Response(validation.error, { status: 401 });
}
try {
await handleMessage(env, validation.update!);
return new Response('OK');
} catch (error) {
console.error('Message handling error:', error);
return new Response('Error', { status: 500 });
}
}
// 루트 경로
if (url.pathname === '/') {
return new Response(`
Telegram Rolling Summary Bot
Endpoints:
GET /health - Health check
GET /webhook-info - Webhook status
GET /setup-webhook - Configure webhook
POST /webhook - Telegram webhook (authenticated)
Documentation: https://github.com/your-repo
`.trim(), {
headers: { 'Content-Type': 'text/plain' },
});
}
return new Response('Not Found', { status: 404 });
},
};

155
src/n8n-service.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Env, IntentAnalysis, N8nResponse } from './types';
// n8n으로 처리할 기능 목록
const N8N_CAPABILITIES = [
'weather', // 날씨
'search', // 검색
'image', // 이미지 생성
'translate', // 번역
'schedule', // 일정
'reminder', // 알림
'news', // 뉴스
'calculate', // 계산
'summarize_url', // URL 요약
];
// AI가 의도를 분석하여 n8n 호출 여부 결정
export async function analyzeIntent(
ai: Ai,
userMessage: string
): Promise<IntentAnalysis> {
const prompt = `사용자 메시지를 분석하여 어떤 처리가 필요한지 JSON으로 응답하세요.
## 외부 기능이 필요한 경우 (action: "n8n")
- 날씨 정보: type = "weather"
- 웹 검색: type = "search"
- 이미지 생성: type = "image"
- 번역: type = "translate"
- 일정/캘린더: type = "schedule"
- 알림 설정: type = "reminder"
- 뉴스: type = "news"
- 복잡한 계산: type = "calculate"
- URL/링크 요약: type = "summarize_url"
## 일반 대화인 경우 (action: "chat")
- 인사, 잡담, 질문, 조언 요청 등
## 응답 형식 (JSON만 출력)
{"action": "n8n", "type": "weather", "confidence": 0.9}
또는
{"action": "chat", "confidence": 0.95}
## 사용자 메시지
${userMessage}
JSON:`;
try {
const response = await ai.run('@cf/meta/llama-3.1-8b-instruct', {
messages: [{ role: 'user', content: prompt }],
max_tokens: 100,
});
const text = response.response || '';
// JSON 추출
const jsonMatch = text.match(/\{[^}]+\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
return {
action: parsed.action || 'chat',
type: parsed.type,
confidence: parsed.confidence || 0.5,
};
}
} catch (error) {
console.error('Intent analysis error:', error);
}
// 기본값: 일반 대화
return { action: 'chat', confidence: 0.5 };
}
// n8n Webhook 호출
export async function callN8n(
env: Env,
type: string,
userMessage: string,
userId: number,
chatId: string,
userProfile?: string | null
): Promise<N8nResponse> {
if (!env.N8N_WEBHOOK_URL) {
return { error: 'n8n이 설정되지 않았습니다.' };
}
const webhookUrl = `${env.N8N_WEBHOOK_URL}/webhook/telegram-bot`;
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
message: userMessage,
user_id: userId,
chat_id: chatId,
profile: userProfile,
timestamp: new Date().toISOString(),
}),
});
if (!response.ok) {
console.error('n8n error:', response.status, await response.text());
return { error: `n8n 호출 실패 (${response.status})` };
}
const data = await response.json() as N8nResponse;
return data;
} catch (error) {
console.error('n8n fetch error:', error);
return { error: 'n8n 연결 실패' };
}
}
// 스마트 라우팅: AI 판단 → n8n 또는 로컬 AI
export async function smartRoute(
env: Env,
userMessage: string,
userId: number,
chatId: string,
userProfile?: string | null
): Promise<{ useN8n: boolean; n8nType?: string; n8nResponse?: N8nResponse }> {
// n8n URL이 없으면 항상 로컬 AI 사용
if (!env.N8N_WEBHOOK_URL) {
return { useN8n: false };
}
// 의도 분석
const intent = await analyzeIntent(env.AI, userMessage);
// n8n 호출이 필요하고 신뢰도가 높은 경우
if (intent.action === 'n8n' && intent.confidence >= 0.7 && intent.type) {
const n8nResponse = await callN8n(
env,
intent.type,
userMessage,
userId,
chatId,
userProfile
);
// n8n 응답이 성공적인 경우
if (n8nResponse.reply && !n8nResponse.error) {
return {
useN8n: true,
n8nType: intent.type,
n8nResponse,
};
}
}
return { useN8n: false };
}

272
src/openai-service.ts Normal file
View File

@@ -0,0 +1,272 @@
import { Env } from './types';
interface OpenAIMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
interface OpenAIResponse {
choices: {
message: OpenAIMessage;
finish_reason: string;
}[];
}
// 사용 가능한 도구 정의
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: '특정 도시의 현재 날씨 정보를 가져옵니다',
parameters: {
type: 'object',
properties: {
city: {
type: 'string',
description: '도시 이름 (예: Seoul, Tokyo, New York)',
},
},
required: ['city'],
},
},
},
{
type: 'function',
function: {
name: 'search_web',
description: '웹에서 정보를 검색합니다',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '검색 쿼리',
},
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'get_current_time',
description: '현재 시간을 가져옵니다',
parameters: {
type: 'object',
properties: {
timezone: {
type: 'string',
description: '타임존 (예: Asia/Seoul, UTC)',
},
},
required: [],
},
},
},
{
type: 'function',
function: {
name: 'calculate',
description: '수학 계산을 수행합니다',
parameters: {
type: 'object',
properties: {
expression: {
type: 'string',
description: '계산할 수식 (예: 2+2, 100*5)',
},
},
required: ['expression'],
},
},
},
];
// 도구 실행
async function executeTool(name: string, args: Record<string, string>): Promise<string> {
switch (name) {
case 'get_weather': {
const city = args.city || 'Seoul';
try {
const response = await fetch(
`https://wttr.in/${encodeURIComponent(city)}?format=j1`
);
const data = await response.json() as any;
const current = data.current_condition[0];
return `🌤 ${city} 날씨
온도: ${current.temp_C}°C (체감 ${current.FeelsLikeC}°C)
상태: ${current.weatherDesc[0].value}
습도: ${current.humidity}%
풍속: ${current.windspeedKmph} km/h`;
} catch (error) {
return `날씨 정보를 가져올 수 없습니다: ${city}`;
}
}
case 'search_web': {
// 간단한 DuckDuckGo Instant Answer API
const query = args.query;
try {
const response = await fetch(
`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1`
);
const data = await response.json() as any;
if (data.Abstract) {
return `🔍 검색 결과: ${query}\n\n${data.Abstract}\n\n출처: ${data.AbstractSource}`;
} else if (data.RelatedTopics?.length > 0) {
const topics = data.RelatedTopics.slice(0, 3)
.filter((t: any) => t.Text)
.map((t: any) => `${t.Text}`)
.join('\n');
return `🔍 관련 정보: ${query}\n\n${topics}`;
}
return `"${query}"에 대한 즉시 답변을 찾을 수 없습니다. 더 구체적인 질문을 해주세요.`;
} catch (error) {
return `검색 중 오류가 발생했습니다.`;
}
}
case 'get_current_time': {
const timezone = args.timezone || 'Asia/Seoul';
try {
const now = new Date();
const formatted = now.toLocaleString('ko-KR', { timeZone: timezone });
return `🕐 현재 시간 (${timezone}): ${formatted}`;
} catch (error) {
return `시간 정보를 가져올 수 없습니다.`;
}
}
case 'calculate': {
const expression = args.expression;
try {
// 안전한 수식 계산 (기본 연산만)
const sanitized = expression.replace(/[^0-9+\-*/().% ]/g, '');
const result = Function('"use strict"; return (' + sanitized + ')')();
return `🔢 계산 결과: ${expression} = ${result}`;
} catch (error) {
return `계산할 수 없는 수식입니다: ${expression}`;
}
}
default:
return `알 수 없는 도구: ${name}`;
}
}
// OpenAI API 호출
async function callOpenAI(
apiKey: string,
messages: OpenAIMessage[],
useTools: boolean = true
): Promise<OpenAIResponse> {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
tools: useTools ? tools : undefined,
tool_choice: useTools ? 'auto' : undefined,
max_tokens: 1000,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
}
return response.json();
}
// 메인 응답 생성 함수
export async function generateOpenAIResponse(
env: Env,
userMessage: string,
systemPrompt: string,
recentContext: { role: 'user' | 'assistant'; content: string }[]
): Promise<string> {
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const messages: OpenAIMessage[] = [
{ role: 'system', content: systemPrompt },
...recentContext.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
{ role: 'user', content: userMessage },
];
// 첫 번째 호출
let response = await callOpenAI(env.OPENAI_API_KEY, messages);
let assistantMessage = response.choices[0].message;
// Function Calling 처리 (최대 3회 반복)
let iterations = 0;
while (assistantMessage.tool_calls && iterations < 3) {
iterations++;
// 도구 호출 결과 수집
const toolResults: OpenAIMessage[] = [];
for (const toolCall of assistantMessage.tool_calls) {
const args = JSON.parse(toolCall.function.arguments);
const result = await executeTool(toolCall.function.name, args);
toolResults.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result,
});
}
// 대화에 추가
messages.push({
role: 'assistant',
content: assistantMessage.content,
tool_calls: assistantMessage.tool_calls,
});
messages.push(...toolResults);
// 다시 호출
response = await callOpenAI(env.OPENAI_API_KEY, messages, false);
assistantMessage = response.choices[0].message;
}
return assistantMessage.content || '응답을 생성할 수 없습니다.';
}
// 프로필 생성용 (도구 없이)
export async function generateProfileWithOpenAI(
env: Env,
prompt: string
): Promise<string> {
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const response = await callOpenAI(
env.OPENAI_API_KEY,
[{ role: 'user', content: prompt }],
false
);
return response.choices[0].message.content || '프로필 생성 실패';
}

159
src/security.ts Normal file
View File

@@ -0,0 +1,159 @@
import { Env, TelegramUpdate } from './types';
// Telegram 서버 IP 대역 (2024년 기준)
// https://core.telegram.org/bots/webhooks#the-short-version
const TELEGRAM_IP_RANGES = [
'149.154.160.0/20',
'91.108.4.0/22',
];
// CIDR 범위 체크 유틸
function ipInCIDR(ip: string, cidr: string): boolean {
const [range, bits] = cidr.split('/');
const mask = ~(2 ** (32 - parseInt(bits)) - 1);
const ipNum = ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
const rangeNum = range.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
return (ipNum & mask) === (rangeNum & mask);
}
// IP 화이트리스트 검증
function isValidTelegramIP(ip: string): boolean {
return TELEGRAM_IP_RANGES.some(range => ipInCIDR(ip, range));
}
// Webhook Secret Token 검증 (Timing-safe comparison)
function isValidSecretToken(request: Request, expectedSecret: string): boolean {
const secretHeader = request.headers.get('X-Telegram-Bot-Api-Secret-Token');
if (!secretHeader || !expectedSecret) {
return false;
}
// Timing-safe comparison
if (secretHeader.length !== expectedSecret.length) {
return false;
}
let result = 0;
for (let i = 0; i < secretHeader.length; i++) {
result |= secretHeader.charCodeAt(i) ^ expectedSecret.charCodeAt(i);
}
return result === 0;
}
// 요청 본문 검증
function isValidRequestBody(body: unknown): body is TelegramUpdate {
return (
body !== null &&
typeof body === 'object' &&
'update_id' in body &&
typeof (body as TelegramUpdate).update_id === 'number'
);
}
// 타임스탬프 검증 (리플레이 공격 방지)
function isRecentUpdate(message: TelegramUpdate['message']): boolean {
if (!message?.date) return true; // 메시지가 없으면 통과
const messageTime = message.date * 1000; // Unix timestamp to ms
const now = Date.now();
const maxAge = 60 * 1000; // 60초
return now - messageTime < maxAge;
}
export interface SecurityCheckResult {
valid: boolean;
error?: string;
update?: TelegramUpdate;
}
// 통합 보안 검증
export async function validateWebhookRequest(
request: Request,
env: Env
): Promise<SecurityCheckResult> {
// 1. HTTP 메서드 검증
if (request.method !== 'POST') {
return { valid: false, error: 'Method not allowed' };
}
// 2. Content-Type 검증
const contentType = request.headers.get('Content-Type');
if (!contentType?.includes('application/json')) {
return { valid: false, error: 'Invalid content type' };
}
// 3. Secret Token 검증 (필수)
if (env.WEBHOOK_SECRET) {
if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) {
console.error('Invalid webhook secret token');
return { valid: false, error: 'Invalid secret token' };
}
} else {
console.warn('WEBHOOK_SECRET not configured - skipping token validation');
}
// 4. IP 화이트리스트 검증 (선택적 - CF에서는 CF-Connecting-IP 사용)
const clientIP = request.headers.get('CF-Connecting-IP');
if (clientIP && !isValidTelegramIP(clientIP)) {
// 경고만 로그 (Cloudflare 프록시 환경에서는 정확하지 않을 수 있음)
console.warn(`Request from non-Telegram IP: ${clientIP}`);
}
// 5. 요청 본문 파싱 및 검증
let body: unknown;
try {
body = await request.json();
} catch {
return { valid: false, error: 'Invalid JSON body' };
}
if (!isValidRequestBody(body)) {
return { valid: false, error: 'Invalid request body structure' };
}
// 6. 타임스탬프 검증 (리플레이 공격 방지)
if (!isRecentUpdate(body.message)) {
return { valid: false, error: 'Message too old' };
}
return { valid: true, update: body };
}
// Rate Limiting
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
export function checkRateLimit(
userId: string,
maxRequests: number = 30,
windowMs: number = 60000
): boolean {
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
if (!userLimit || now > userLimit.resetAt) {
rateLimitMap.set(userId, { count: 1, resetAt: now + windowMs });
return true;
}
if (userLimit.count >= maxRequests) {
return false;
}
userLimit.count++;
return true;
}
// Rate limit 정리 (메모리 관리)
export function cleanupRateLimits(): void {
const now = Date.now();
for (const [key, value] of rateLimitMap.entries()) {
if (now > value.resetAt) {
rateLimitMap.delete(key);
}
}
}

277
src/summary-service.ts Normal file
View File

@@ -0,0 +1,277 @@
import { Env, BufferedMessage, Summary, ConversationContext } from './types';
// 설정값 가져오기
const getConfig = (env: Env) => ({
summaryThreshold: parseInt(env.SUMMARY_THRESHOLD || '20', 10),
maxSummaries: parseInt(env.MAX_SUMMARIES_PER_USER || '3', 10),
});
// 버퍼에 메시지 추가
export async function addToBuffer(
db: D1Database,
userId: number,
chatId: string,
role: 'user' | 'bot',
message: string
): Promise<number> {
await db
.prepare(`
INSERT INTO message_buffer (user_id, chat_id, role, message)
VALUES (?, ?, ?, ?)
`)
.bind(userId, chatId, role, message)
.run();
const count = await db
.prepare('SELECT COUNT(*) as cnt FROM message_buffer WHERE user_id = ? AND chat_id = ?')
.bind(userId, chatId)
.first<{ cnt: number }>();
return count?.cnt || 0;
}
// 버퍼 메시지 조회
export async function getBufferedMessages(
db: D1Database,
userId: number,
chatId: string
): Promise<BufferedMessage[]> {
const { results } = await db
.prepare(`
SELECT id, role, message, created_at
FROM message_buffer
WHERE user_id = ? AND chat_id = ?
ORDER BY created_at ASC
`)
.bind(userId, chatId)
.all();
return (results || []) as BufferedMessage[];
}
// 최신 요약 조회
export async function getLatestSummary(
db: D1Database,
userId: number,
chatId: string
): Promise<Summary | null> {
const summary = await db
.prepare(`
SELECT id, generation, summary, message_count, created_at
FROM summaries
WHERE user_id = ? AND chat_id = ?
ORDER BY generation DESC
LIMIT 1
`)
.bind(userId, chatId)
.first<Summary>();
return summary || null;
}
// 전체 컨텍스트 조회
export async function getConversationContext(
db: D1Database,
userId: number,
chatId: string
): Promise<ConversationContext> {
const [previousSummary, recentMessages] = await Promise.all([
getLatestSummary(db, userId, chatId),
getBufferedMessages(db, userId, chatId),
]);
const totalMessages = (previousSummary?.message_count || 0) + recentMessages.length;
return {
previousSummary,
recentMessages,
totalMessages,
};
}
// AI 요약 생성
async function generateSummary(
env: Env,
previousSummary: string | null,
messages: BufferedMessage[]
): Promise<string> {
// 사용자 메시지만 추출
const userMessages = messages
.filter((m) => m.role === 'user')
.map((m) => `- ${m.message}`)
.join('\n');
// 사용자 메시지 수
const userMsgCount = messages.filter((m) => m.role === 'user').length;
let prompt: string;
if (previousSummary) {
prompt = `당신은 사용자 프로필 분석 전문가입니다.
기존 사용자 프로필과 새로운 대화를 통합하여 사용자에 대한 이해를 업데이트하세요.
## 기존 사용자 프로필
${previousSummary}
## 새로운 사용자 발언 (${userMsgCount}개)
${userMessages}
## 요구사항
1. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석
2. **의미 있는 정보 추출**:
- 사용자의 관심사, 취미, 선호도
- 질문한 주제들 (무엇에 대해 알고 싶어하는지)
- 요청사항, 목표, 해결하려는 문제
- 개인적 맥락 (직업, 상황, 배경 등)
- 감정 상태나 태도 변화
3. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략
4. **간결하게**: 300-400자 이내
5. **한국어로 작성**
업데이트된 사용자 프로필:`;
} else {
prompt = `당신은 사용자 프로필 분석 전문가입니다.
대화 내용에서 사용자에 대한 정보를 추출하여 프로필을 작성하세요.
## 사용자 발언 (${userMsgCount}개)
${userMessages}
## 요구사항
1. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석
2. **의미 있는 정보 추출**:
- 사용자의 관심사, 취미, 선호도
- 질문한 주제들 (무엇에 대해 알고 싶어하는지)
- 요청사항, 목표, 해결하려는 문제
- 개인적 맥락 (직업, 상황, 배경 등)
3. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략
4. **간결하게**: 200-300자 이내
5. **한국어로 작성**
사용자 프로필:`;
}
// OpenAI 사용 (설정된 경우)
if (env.OPENAI_API_KEY) {
const { generateProfileWithOpenAI } = await import('./openai-service');
return generateProfileWithOpenAI(env, prompt);
}
// 폴백: Workers AI
const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
messages: [{ role: 'user', content: prompt }],
max_tokens: 500,
});
return response.response || '프로필 생성 실패';
}
// 오래된 요약 정리
async function cleanupOldSummaries(
db: D1Database,
userId: number,
chatId: string,
maxSummaries: number
): Promise<void> {
await db
.prepare(`
DELETE FROM summaries
WHERE user_id = ? AND chat_id = ?
AND id NOT IN (
SELECT id FROM summaries
WHERE user_id = ? AND chat_id = ?
ORDER BY generation DESC
LIMIT ?
)
`)
.bind(userId, chatId, userId, chatId, maxSummaries)
.run();
}
// 요약 실행 및 저장
export async function processAndSummarize(
env: Env,
userId: number,
chatId: string
): Promise<{ summarized: boolean; summary?: string }> {
const config = getConfig(env);
const messages = await getBufferedMessages(env.DB, userId, chatId);
if (messages.length < config.summaryThreshold) {
return { summarized: false };
}
const previousSummary = await getLatestSummary(env.DB, userId, chatId);
// AI 요약 생성
const newSummary = await generateSummary(
env,
previousSummary?.summary || null,
messages
);
const newGeneration = (previousSummary?.generation || 0) + 1;
const newMessageCount = (previousSummary?.message_count || 0) + messages.length;
// 트랜잭션 실행
await env.DB.batch([
env.DB
.prepare(`
INSERT INTO summaries (user_id, chat_id, generation, summary, message_count)
VALUES (?, ?, ?, ?, ?)
`)
.bind(userId, chatId, newGeneration, newSummary, newMessageCount),
env.DB
.prepare('DELETE FROM message_buffer WHERE user_id = ? AND chat_id = ?')
.bind(userId, chatId),
]);
// 오래된 요약 정리
await cleanupOldSummaries(env.DB, userId, chatId, config.maxSummaries);
return { summarized: true, summary: newSummary };
}
// AI 응답 생성 (컨텍스트 포함)
export async function generateAIResponse(
env: Env,
userId: number,
chatId: string,
userMessage: string
): Promise<string> {
const context = await getConversationContext(env.DB, userId, chatId);
const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다.
${context.previousSummary ? `
## 사용자 프로필
${context.previousSummary.summary}
위 프로필을 바탕으로 사용자의 관심사와 맥락을 이해하고 개인화된 응답을 제공하세요.
` : ''}
- 날씨, 시간, 계산, 검색 등의 요청은 제공된 도구를 사용하세요.
- 응답은 간결하고 도움이 되도록 한국어로 작성하세요.`;
const recentContext = context.recentMessages.slice(-10).map((m) => ({
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
content: m.message,
}));
// OpenAI 사용 (설정된 경우)
if (env.OPENAI_API_KEY) {
const { generateOpenAIResponse } = await import('./openai-service');
return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext);
}
// 폴백: Workers AI
const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
messages: [
{ role: 'system', content: systemPrompt },
...recentContext,
{ role: 'user', content: userMessage },
],
max_tokens: 500,
});
return response.response || '응답을 생성할 수 없습니다.';
}

107
src/telegram.ts Normal file
View File

@@ -0,0 +1,107 @@
// Telegram API 메시지 전송
export async function sendMessage(
token: string,
chatId: number,
text: string,
options?: {
parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2';
reply_to_message_id?: number;
disable_notification?: boolean;
}
): Promise<boolean> {
try {
const response = await fetch(
`https://api.telegram.org/bot${token}/sendMessage`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: options?.parse_mode || 'HTML',
reply_to_message_id: options?.reply_to_message_id,
disable_notification: options?.disable_notification,
}),
}
);
if (!response.ok) {
const error = await response.text();
console.error('Telegram API error:', error);
return false;
}
return true;
} catch (error) {
console.error('Failed to send message:', error);
return false;
}
}
// Webhook 설정 (Secret Token 포함)
export async function setWebhook(
token: string,
webhookUrl: string,
secretToken: string
): Promise<{ ok: boolean; description?: string }> {
const response = await fetch(
`https://api.telegram.org/bot${token}/setWebhook`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: webhookUrl,
secret_token: secretToken,
allowed_updates: ['message'],
drop_pending_updates: true,
}),
}
);
return response.json();
}
// Webhook 정보 조회
export async function getWebhookInfo(
token: string
): Promise<unknown> {
const response = await fetch(
`https://api.telegram.org/bot${token}/getWebhookInfo`
);
return response.json();
}
// Webhook 삭제
export async function deleteWebhook(
token: string
): Promise<{ ok: boolean }> {
const response = await fetch(
`https://api.telegram.org/bot${token}/deleteWebhook`,
{ method: 'POST' }
);
return response.json();
}
// 타이핑 액션 전송
export async function sendChatAction(
token: string,
chatId: number,
action: 'typing' | 'upload_photo' | 'upload_document' = 'typing'
): Promise<boolean> {
try {
const response = await fetch(
`https://api.telegram.org/bot${token}/sendChatAction`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
action,
}),
}
);
return response.ok;
} catch {
return false;
}
}

68
src/types.ts Normal file
View File

@@ -0,0 +1,68 @@
export interface Env {
DB: D1Database;
AI: Ai;
BOT_TOKEN: string;
WEBHOOK_SECRET: string;
SUMMARY_THRESHOLD?: string;
MAX_SUMMARIES_PER_USER?: string;
N8N_WEBHOOK_URL?: string;
OPENAI_API_KEY?: string;
}
export interface IntentAnalysis {
action: 'chat' | 'n8n';
type?: string;
confidence: number;
}
export interface N8nResponse {
reply?: string;
error?: string;
}
export interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
}
export interface TelegramMessage {
message_id: number;
from: TelegramUser;
chat: TelegramChat;
date: number;
text?: string;
}
export interface TelegramUser {
id: number;
is_bot: boolean;
first_name: string;
last_name?: string;
username?: string;
}
export interface TelegramChat {
id: number;
type: string;
}
export interface BufferedMessage {
id: number;
role: 'user' | 'bot';
message: string;
created_at: string;
}
export interface Summary {
id: number;
generation: number;
summary: string;
message_count: number;
created_at: string;
}
export interface ConversationContext {
previousSummary: Summary | null;
recentMessages: BufferedMessage[];
totalMessages: number;
}