Files
telegram-bot-workers/docs/plans/2026-02-05-conversation-storage-implementation.md
kappa 7d43db3054 refactor: delete server-agent.ts (905 lines)
Remove server recommendation consultation system:
- 30-year expert AI persona
- Session-based information gathering
- Brave Search / Context7 tool integration
- Automatic spec inference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:25:36 +09:00

30 KiB

대화 저장 시스템 구현 계획

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 요약 기반 대화 저장을 전체 대화 기록 시스템으로 전환

Architecture: 메타 테이블(conversation_tables) + 사용자별 동적 테이블(conv_{telegram_id}) + 6개월 아카이브

Tech Stack: Cloudflare Workers, D1 SQLite, TypeScript, OpenAI GPT-4o-mini


Task 1: 타입 정의 추가

Files:

  • Modify: src/types.ts

Step 1: ConversationMessage 타입 추가

src/types.ts 파일 끝에 추가:

// ============================================
// Conversation Storage Types
// ============================================

export interface ConversationMessage {
  id?: number;
  role: 'user' | 'assistant';
  content: string;
  tool_calls?: string;    // JSON: [{name, arguments, id}]
  tool_results?: string;  // JSON: [{tool_call_id, result}]
  created_at?: string;
}

export interface ConversationTableMeta {
  telegram_id: string;
  table_name: string;
  message_count: number;
  last_message_at: string | null;
  created_at: string;
}

export interface ConversationStats {
  telegram_id: string;
  message_count: number;
  first_message_at: string | null;
  last_message_at: string | null;
  archived_summaries: number;
}

export interface ArchiveResult {
  processed_users: number;
  archived_messages: number;
  created_summaries: number;
  errors: string[];
}

Step 2: 테스트 (타입 체크)

Run: npm run typecheck Expected: PASS (no type errors)

Step 3: Commit

git add src/types.ts
git commit -m "feat: add conversation storage types"

Task 2: 마이그레이션 SQL 작성

Files:

  • Create: migrations/008_conversation_tables.sql

Step 1: 마이그레이션 파일 생성

-- 대화 테이블 메타 정보
CREATE TABLE IF NOT EXISTS conversation_tables (
  telegram_id TEXT PRIMARY KEY,
  table_name TEXT NOT NULL,
  message_count INTEGER DEFAULT 0,
  last_message_at DATETIME,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 인덱스
CREATE INDEX IF NOT EXISTS idx_conv_tables_last_msg ON conversation_tables(last_message_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_tables_count ON conversation_tables(message_count DESC);

Step 2: 로컬 테스트

Run: wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql Expected: 성공 메시지

Step 3: Commit

git add migrations/008_conversation_tables.sql
git commit -m "feat: add conversation_tables migration"

Task 3: 대화 저장 서비스 구현

Files:

  • Create: src/services/conversation-storage.ts

Step 1: 기본 구조 작성

import type { Env, ConversationMessage, ConversationTableMeta, ConversationStats } from '../types';
import { createLogger } from '../utils/logger';

const logger = createLogger('conversation-storage');

// 불용어 목록 (키워드 추출 시 제외)
const STOP_WORDS = new Set([
  '은', '는', '이', '가', '을', '를', '에', '도', '로', '의', '와', '과',
  '에서', '으로', '하고', '해서', '했어', '할게', '할까', '해줘', '해주세요',
  '좀', '그', '저', '이거', '저거', '그거', '뭐', '어떻게', '왜', '언제',
  '네', '예', '아니', '응', '음', '아', '오', 'ㅇㅇ', 'ㄴㄴ'
]);

/**
 * 사용자별 대화 테이블 존재 확인 및 생성
 */
export async function ensureConversationTable(
  db: D1Database,
  telegramId: string
): Promise<string> {
  const tableName = `conv_${telegramId}`;

  // 1. 메타 테이블에서 확인
  const existing = await db
    .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?')
    .bind(telegramId)
    .first<{ table_name: string }>();

  if (existing) {
    return existing.table_name;
  }

  // 2. 테이블 생성 (raw SQL - DDL은 prepared statement 불가)
  await db.exec(`
    CREATE TABLE IF NOT EXISTS ${tableName} (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
      content TEXT NOT NULL,
      tool_calls TEXT,
      tool_results TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);

  // 3. 인덱스 생성
  await db.exec(`
    CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC)
  `);

  // 4. 메타 테이블에 등록
  await db
    .prepare('INSERT INTO conversation_tables (telegram_id, table_name) VALUES (?, ?)')
    .bind(telegramId, tableName)
    .run();

  logger.info('대화 테이블 생성', { telegramId, tableName });

  return tableName;
}

/**
 * 메시지 저장
 */
export async function saveConversationMessage(
  db: D1Database,
  telegramId: string,
  message: Omit<ConversationMessage, 'id' | 'created_at'>
): Promise<void> {
  const tableName = await ensureConversationTable(db, telegramId);

  // 1. 메시지 저장
  await db
    .prepare(`INSERT INTO ${tableName} (role, content, tool_calls, tool_results) VALUES (?, ?, ?, ?)`)
    .bind(
      message.role,
      message.content,
      message.tool_calls || null,
      message.tool_results || null
    )
    .run();

  // 2. 메타 테이블 업데이트
  await db
    .prepare(`
      UPDATE conversation_tables
      SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP
      WHERE telegram_id = ?
    `)
    .bind(telegramId)
    .run();
}

/**
 * 최근 메시지 조회
 */
export async function getRecentConversations(
  db: D1Database,
  telegramId: string,
  limit: number = 20
): Promise<ConversationMessage[]> {
  // 테이블 존재 확인
  const meta = await db
    .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?')
    .bind(telegramId)
    .first<{ table_name: string }>();

  if (!meta) {
    return [];
  }

  const { results } = await db
    .prepare(`
      SELECT id, role, content, tool_calls, tool_results, created_at
      FROM ${meta.table_name}
      ORDER BY created_at DESC
      LIMIT ?
    `)
    .bind(limit)
    .all<ConversationMessage>();

  // 시간순 정렬 (오래된 것 → 최신)
  return (results || []).reverse();
}

/**
 * 키워드로 관련 메시지 검색
 */
export async function searchConversations(
  db: D1Database,
  telegramId: string,
  keywords: string[],
  limit: number = 10
): Promise<ConversationMessage[]> {
  const meta = await db
    .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?')
    .bind(telegramId)
    .first<{ table_name: string }>();

  if (!meta || keywords.length === 0) {
    return [];
  }

  // LIKE 쿼리 생성 (OR 조건)
  const likeConditions = keywords.map(() => `content LIKE ?`).join(' OR ');
  const likeValues = keywords.map(k => `%${k}%`);

  const { results } = await db
    .prepare(`
      SELECT id, role, content, tool_calls, tool_results, created_at
      FROM ${meta.table_name}
      WHERE ${likeConditions}
      ORDER BY created_at DESC
      LIMIT ?
    `)
    .bind(...likeValues, limit)
    .all<ConversationMessage>();

  return results || [];
}

/**
 * 메시지에서 키워드 추출
 */
export function extractKeywords(message: string): string[] {
  // 1. 특수문자 제거, 공백으로 분리
  const words = message
    .replace(/[^\w\s가-힣]/g, ' ')
    .split(/\s+/)
    .filter(w => w.length >= 2);

  // 2. 불용어 제거
  const filtered = words.filter(w => !STOP_WORDS.has(w));

  // 3. 중복 제거 후 최대 5개
  const unique = [...new Set(filtered)];

  return unique.slice(0, 5);
}

/**
 * 스마트 컨텍스트 조회 (최근 20개 + 관련 10개)
 */
export async function getSmartContext(
  db: D1Database,
  telegramId: string,
  currentMessage: string
): Promise<ConversationMessage[]> {
  // 1. 최근 20개 조회
  const recent = await getRecentConversations(db, telegramId, 20);

  // 2. 키워드 추출
  const keywords = extractKeywords(currentMessage);

  if (keywords.length === 0) {
    return recent;
  }

  // 3. 관련 대화 검색 (최근 20개 제외)
  const recentIds = new Set(recent.map(m => m.id));
  const related = await searchConversations(db, telegramId, keywords, 20);
  const filteredRelated = related
    .filter(m => !recentIds.has(m.id))
    .slice(0, 10);

  // 4. 병합 및 시간순 정렬
  const combined = [...filteredRelated, ...recent];
  combined.sort((a, b) => {
    const timeA = new Date(a.created_at || 0).getTime();
    const timeB = new Date(b.created_at || 0).getTime();
    return timeA - timeB;
  });

  return combined;
}

/**
 * 대화 통계 조회
 */
export async function getConversationStats(
  db: D1Database,
  telegramId: string
): Promise<ConversationStats | null> {
  const meta = await db
    .prepare('SELECT * FROM conversation_tables WHERE telegram_id = ?')
    .bind(telegramId)
    .first<ConversationTableMeta>();

  if (!meta) {
    return null;
  }

  // 첫 메시지 시간 조회
  const firstMsg = await db
    .prepare(`SELECT MIN(created_at) as first_at FROM ${meta.table_name}`)
    .first<{ first_at: string | null }>();

  // 아카이브된 요약 수
  const archiveCount = await db
    .prepare('SELECT COUNT(*) as cnt FROM summaries WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)')
    .bind(telegramId)
    .first<{ cnt: number }>();

  return {
    telegram_id: telegramId,
    message_count: meta.message_count,
    first_message_at: firstMsg?.first_at || null,
    last_message_at: meta.last_message_at,
    archived_summaries: archiveCount?.cnt || 0,
  };
}

/**
 * 대화 히스토리 조회 (명령어용)
 */
export async function getConversationHistory(
  db: D1Database,
  telegramId: string,
  limit: number = 20,
  offset: number = 0
): Promise<ConversationMessage[]> {
  const meta = await db
    .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?')
    .bind(telegramId)
    .first<{ table_name: string }>();

  if (!meta) {
    return [];
  }

  const { results } = await db
    .prepare(`
      SELECT id, role, content, tool_calls, tool_results, created_at
      FROM ${meta.table_name}
      ORDER BY created_at DESC
      LIMIT ? OFFSET ?
    `)
    .bind(limit, offset)
    .all<ConversationMessage>();

  return (results || []).reverse();
}

Step 2: 타입 체크

Run: npm run typecheck Expected: PASS

Step 3: Commit

git add src/services/conversation-storage.ts
git commit -m "feat: implement conversation storage service"

Task 4: 아카이브 서비스 구현

Files:

  • Create: src/services/archive-service.ts

Step 1: 아카이브 서비스 작성

import type { Env, ConversationMessage, ArchiveResult } from '../types';
import { createLogger } from '../utils/logger';

const logger = createLogger('archive-service');

const ARCHIVE_DAYS = 180; // 6개월
const BATCH_SIZE = 100;   // 요약당 메시지 수

/**
 * 메시지 배열을 AI 요약으로 변환
 */
async function generateArchiveSummary(
  env: Env,
  messages: ConversationMessage[],
  startDate: string,
  endDate: string
): Promise<string> {
  // 사용자 메시지만 추출
  const userMessages = messages
    .filter(m => m.role === 'user')
    .map(m => `- ${m.content}`)
    .join('\n');

  const prompt = `당신은 대화 아카이브 전문가입니다.
아래 기간의 사용자 발언을 분석하여 핵심 정보를 요약하세요.

## 기간
${startDate} ~ ${endDate}

## 사용자 발언 (${messages.filter(m => m.role === 'user').length}개)
${userMessages}

## 요구사항
1. 주요 관심사, 요청사항, 질문 주제를 파악
2. 중요한 맥락 정보 (직업, 프로젝트, 목표 등) 보존
3. 무의미한 내용 (인사, 단순 확인) 제외
4. 300-400자 이내로 작성
5. 한국어로 작성

아카이브 요약:`;

  if (env.OPENAI_API_KEY) {
    const { generateProfileWithOpenAI } = await import('../openai-service');
    const summary = await generateProfileWithOpenAI(env, prompt);
    return `[${startDate} ~ ${endDate} 아카이브] ${summary}`;
  }

  // 폴백: 단순 요약
  return `[${startDate} ~ ${endDate} 아카이브] 이 기간 동안 ${messages.length}개의 대화가 있었습니다.`;
}

/**
 * 특정 사용자의 오래된 대화 아카이브
 */
export async function archiveUserConversations(
  env: Env,
  telegramId: string,
  tableName: string,
  olderThanDays: number = ARCHIVE_DAYS
): Promise<{ archived: number; summaries: number }> {
  const db = env.DB;
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
  const cutoffStr = cutoffDate.toISOString();

  // 1. 아카이브 대상 메시지 조회
  const { results: oldMessages } = await db
    .prepare(`
      SELECT id, role, content, tool_calls, tool_results, created_at
      FROM ${tableName}
      WHERE created_at < ?
      ORDER BY created_at ASC
    `)
    .bind(cutoffStr)
    .all<ConversationMessage>();

  if (!oldMessages || oldMessages.length === 0) {
    return { archived: 0, summaries: 0 };
  }

  logger.info('아카이브 대상', { telegramId, count: oldMessages.length });

  // 2. 사용자 ID 조회
  const user = await db
    .prepare('SELECT id FROM users WHERE telegram_id = ?')
    .bind(telegramId)
    .first<{ id: number }>();

  if (!user) {
    logger.warn('사용자 없음', { telegramId });
    return { archived: 0, summaries: 0 };
  }

  // 3. BATCH_SIZE 단위로 요약 생성
  let summariesCreated = 0;
  const messageIds: number[] = [];

  for (let i = 0; i < oldMessages.length; i += BATCH_SIZE) {
    const batch = oldMessages.slice(i, i + BATCH_SIZE);
    const startDate = batch[0].created_at?.split('T')[0] || 'unknown';
    const endDate = batch[batch.length - 1].created_at?.split('T')[0] || 'unknown';

    // 요약 생성
    const summary = await generateArchiveSummary(env, batch, startDate, endDate);

    // 최신 generation 조회
    const latestGen = await db
      .prepare('SELECT MAX(generation) as gen FROM summaries WHERE user_id = ?')
      .bind(user.id)
      .first<{ gen: number | null }>();

    const newGeneration = (latestGen?.gen || 0) + 1;

    // summaries 테이블에 저장
    await db
      .prepare(`
        INSERT INTO summaries (user_id, chat_id, generation, summary, message_count)
        VALUES (?, ?, ?, ?, ?)
      `)
      .bind(user.id, telegramId, newGeneration, summary, batch.length)
      .run();

    summariesCreated++;
    batch.forEach(m => m.id && messageIds.push(m.id));
  }

  // 4. 원본 메시지 삭제
  if (messageIds.length > 0) {
    // SQLite는 IN 절에 많은 파라미터 바인딩이 어려우므로 청크 단위 삭제
    const chunkSize = 100;
    for (let i = 0; i < messageIds.length; i += chunkSize) {
      const chunk = messageIds.slice(i, i + chunkSize);
      const placeholders = chunk.map(() => '?').join(',');
      await db
        .prepare(`DELETE FROM ${tableName} WHERE id IN (${placeholders})`)
        .bind(...chunk)
        .run();
    }
  }

  // 5. 메타 테이블 업데이트
  await db
    .prepare('UPDATE conversation_tables SET message_count = message_count - ? WHERE telegram_id = ?')
    .bind(messageIds.length, telegramId)
    .run();

  logger.info('아카이브 완료', {
    telegramId,
    archived: messageIds.length,
    summaries: summariesCreated
  });

  return { archived: messageIds.length, summaries: summariesCreated };
}

/**
 * 전체 사용자 아카이브 (Cron용)
 */
export async function archiveOldConversations(env: Env): Promise<ArchiveResult> {
  const db = env.DB;
  const result: ArchiveResult = {
    processed_users: 0,
    archived_messages: 0,
    created_summaries: 0,
    errors: [],
  };

  // 1. 모든 대화 테이블 조회
  const { results: tables } = await db
    .prepare('SELECT telegram_id, table_name FROM conversation_tables')
    .all<{ telegram_id: string; table_name: string }>();

  if (!tables || tables.length === 0) {
    logger.info('아카이브 대상 없음');
    return result;
  }

  // 2. 각 사용자별 아카이브 실행
  for (const table of tables) {
    try {
      const { archived, summaries } = await archiveUserConversations(
        env,
        table.telegram_id,
        table.table_name
      );

      if (archived > 0) {
        result.processed_users++;
        result.archived_messages += archived;
        result.created_summaries += summaries;
      }
    } catch (error) {
      const errMsg = error instanceof Error ? error.message : String(error);
      result.errors.push(`${table.telegram_id}: ${errMsg}`);
      logger.error('사용자 아카이브 실패', error as Error, {
        telegramId: table.telegram_id
      });
    }
  }

  logger.info('전체 아카이브 완료', result);
  return result;
}

Step 2: 타입 체크

Run: npm run typecheck Expected: PASS

Step 3: Commit

git add src/services/archive-service.ts
git commit -m "feat: implement archive service for old conversations"

Task 5: summary-service.ts 수정

Files:

  • Modify: src/summary-service.ts

Step 1: getSmartContext 연동

generateAIResponse 함수 수정 - getConversationContext 대신 getSmartContext 사용:

// 기존 import 유지 + 추가
import { getSmartContext } from './services/conversation-storage';

// generateAIResponse 함수 내부 수정
export async function generateAIResponse(
  env: Env,
  userId: number,
  chatId: string,
  userMessage: string,
  telegramUserId?: string
): Promise<string> {
  // 기존 context 대신 스마트 컨텍스트 사용
  const smartMessages = telegramUserId
    ? await getSmartContext(env.DB, telegramUserId, userMessage)
    : [];

  // 아카이브된 요약도 조회 (기존 로직 유지)
  const summaries = await getAllSummaries(env.DB, userId, chatId);

  // 모든 요약 통합 (최신순 → 오래된순으로 정렬하여 시간순 표시)
  const integratedProfile = summaries.length > 0
    ? summaries
        .slice()
        .reverse()
        .map((s) => `[v${s.generation}] ${s.summary}`)
        .join('\n\n')
    : null;

  // ... 기존 systemPrompt 생성 로직 유지 ...

  // recentContext를 스마트 컨텍스트로 교체
  const recentContext = smartMessages.slice(-30).map((m) => ({
    role: m.role === 'user' ? 'user' as const : 'assistant' as const,
    content: m.content,
  }));

  // ... 나머지 로직 유지 ...
}

Step 2: addToBuffer 함수는 유지 (마이그레이션 완료 전까지 호환성)

기존 addToBuffer, getBufferedMessages, processAndSummarize 함수는 삭제하지 않고 유지. 새 시스템과 병행 운영 후 마이그레이션 완료 시 제거.

Step 3: 타입 체크

Run: npm run typecheck Expected: PASS

Step 4: Commit

git add src/summary-service.ts
git commit -m "feat: integrate smart context in AI response generation"

Task 6: conversation-service.ts 수정

Files:

  • Modify: src/services/conversation-service.ts

Step 1: 새 저장 시스템 사용

import type { Env, KeyboardData } from '../types';
import {
  addToBuffer,
  processAndSummarize,
  generateAIResponse,
} from '../summary-service';
import { saveConversationMessage } from './conversation-storage';
import { sendChatAction } from '../telegram';
import { createLogger } from '../utils/logger';

const logger = createLogger('conversation');

export interface ConversationResult {
  responseText: string;
  isProfileUpdated: boolean;
  keyboardData?: KeyboardData | null;
}

export class ConversationService {
  constructor(private env: Env) {}

  async processUserMessage(
    userId: number,
    chatId: string,
    text: string,
    telegramUserId: string
  ): Promise<ConversationResult> {
    // 1. 타이핑 액션 전송
    sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(err =>
      logger.error('타이핑 액션 전송 실패', err as Error)
    );

    // 2. 새 시스템에 사용자 메시지 저장
    await saveConversationMessage(this.env.DB, telegramUserId, {
      role: 'user',
      content: text,
    });

    // 3. 기존 버퍼에도 저장 (호환성 - 나중에 제거)
    await addToBuffer(this.env.DB, userId, chatId, 'user', text);

    // 4. AI 응답 생성
    let responseText = await generateAIResponse(
      this.env,
      userId,
      chatId,
      text,
      telegramUserId
    );

    // __DIRECT__ 마커 제거
    if (responseText.includes('__DIRECT__')) {
      const directIndex = responseText.indexOf('__DIRECT__');
      responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
    }

    // 5. 새 시스템에 봇 응답 저장
    await saveConversationMessage(this.env.DB, telegramUserId, {
      role: 'assistant',
      content: responseText,
    });

    // 6. 기존 버퍼에도 저장 (호환성)
    await addToBuffer(this.env.DB, userId, chatId, 'bot', responseText);

    // 7. 기존 요약 프로세스 유지 (호환성)
    const { summarized } = await processAndSummarize(
      this.env,
      userId,
      chatId
    );

    // 8. 키보드 데이터 파싱
    let keyboardData: KeyboardData | null = null;
    const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s);

    if (keyboardMatch) {
      logger.debug('키보드 마커 감지', { preview: keyboardMatch[1].substring(0, 100) });
      responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/s, '');
      try {
        keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData;
        logger.debug('키보드 파싱 성공', { type: keyboardData.type });
      } catch (e) {
        logger.error('키보드 파싱 오류', e as Error, { rawData: keyboardMatch[1] });
      }
    }

    return {
      responseText,
      isProfileUpdated: summarized,
      keyboardData
    };
  }
}

Step 2: 타입 체크

Run: npm run typecheck Expected: PASS

Step 3: Commit

git add src/services/conversation-service.ts
git commit -m "feat: save messages to new conversation storage"

Files:

  • Modify: src/commands.ts

Step 1: import 추가

import {
  getConversationHistory,
  searchConversations,
  getConversationStats,
  extractKeywords
} from './services/conversation-storage';

Step 2: /history 명령어 추가

case '/history': {
  const limit = _args ? parseInt(_args, 10) : 20;
  const validLimit = Math.min(Math.max(limit, 1), 100); // 1-100 제한

  const messages = await getConversationHistory(env.DB, chatId, validLimit);

  if (messages.length === 0) {
    return '📜 저장된 대화가 없습니다.';
  }

  const formatted = messages.map(m => {
    const time = m.created_at
      ? new Date(m.created_at).toLocaleString('ko-KR', {
          timeZone: 'Asia/Seoul',
          month: '2-digit',
          day: '2-digit',
          hour: '2-digit',
          minute: '2-digit'
        })
      : '';
    const role = m.role === 'user' ? '나' : '봇';
    const content = m.content.length > 50
      ? m.content.substring(0, 50) + '...'
      : m.content;
    return `[${time}] ${role}: ${content}`;
  }).join('\n');

  return `📜 <b>최근 대화</b> (${messages.length}개)\n\n${formatted}\n\n/history 50 으로 더 보기`;
}

case '/search': {
  if (!_args || _args.trim().length < 2) {
    return '🔍 검색어를 입력해주세요.\n예: /search 도메인';
  }

  const keywords = extractKeywords(_args);
  if (keywords.length === 0) {
    return '🔍 유효한 검색어가 없습니다.';
  }

  const results = await searchConversations(env.DB, chatId, keywords, 15);

  if (results.length === 0) {
    return `🔍 "${_args}" 검색 결과가 없습니다.`;
  }

  const formatted = results.map(m => {
    const date = m.created_at
      ? new Date(m.created_at).toLocaleDateString('ko-KR')
      : '';
    const content = m.content.length > 60
      ? m.content.substring(0, 60) + '...'
      : m.content;
    return `[${date}] ${content}`;
  }).join('\n');

  return `🔍 <b>"${_args}"</b> 검색 결과 (${results.length}건)\n\n${formatted}`;
}

Step 3: /stats 명령어 수정

기존 /stats 케이스를 수정하여 새 통계 포함:

case '/stats': {
  const stats = await getConversationStats(env.DB, chatId);

  if (!stats) {
    return '📈 아직 대화 기록이 없습니다.';
  }

  const firstDate = stats.first_message_at
    ? new Date(stats.first_message_at).toLocaleDateString('ko-KR')
    : '없음';
  const lastDate = stats.last_message_at
    ? new Date(stats.last_message_at).toLocaleDateString('ko-KR')
    : '없음';

  return `📈 <b>대화 통계</b>

총 메시지: ${stats.message_count.toLocaleString()}첫 대화: ${firstDate}
최근 대화: ${lastDate}
아카이브된 요약: ${stats.archived_summaries}
/history - 대화 기록 보기
/search 키워드 - 대화 검색`;
}

Step 4: 타입 체크

Run: npm run typecheck Expected: PASS

Step 5: Commit

git add src/commands.ts
git commit -m "feat: add /history and /search commands"

Task 8: Cron에 아카이브 추가

Files:

  • Modify: src/index.ts

Step 1: Cron 핸들러에 아카이브 추가

index.tsscheduled 핸들러 찾아서 아카이브 호출 추가:

// scheduled 핸들러 내부에 추가
async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
  const scheduledLogger = createLogger('scheduled');

  // 기존 로직...

  // UTC 15:00 (KST 00:00) - 아카이브 실행
  if (event.cron === '0 15 * * *') {
    try {
      const { archiveOldConversations } = await import('./services/archive-service');
      const result = await archiveOldConversations(env);
      scheduledLogger.info('아카이브 완료', result);
    } catch (error) {
      scheduledLogger.error('아카이브 실패', error as Error);
    }
  }
}

Step 2: 타입 체크

Run: npm run typecheck Expected: PASS

Step 3: Commit

git add src/index.ts
git commit -m "feat: add conversation archive to daily cron"

Task 9: 기존 데이터 마이그레이션 스크립트

Files:

  • Create: scripts/migrate-conversations.ts

Step 1: 마이그레이션 스크립트 작성

/**
 * 기존 message_buffer 데이터를 새 conversation 테이블로 마이그레이션
 *
 * 실행: npx wrangler d1 execute telegram-conversations --local --file=scripts/migrate-conversations.sql
 * 또는 Worker 내부에서 1회성 실행
 */

import type { Env } from '../src/types';

export async function migrateExistingConversations(env: Env): Promise<{
  migrated_users: number;
  migrated_messages: number;
  errors: string[];
}> {
  const db = env.DB;
  const result = {
    migrated_users: 0,
    migrated_messages: 0,
    errors: [] as string[],
  };

  // 1. 기존 message_buffer에서 사용자 목록 조회
  const { results: users } = await db
    .prepare(`
      SELECT DISTINCT u.telegram_id, mb.user_id
      FROM message_buffer mb
      JOIN users u ON mb.user_id = u.id
    `)
    .all<{ telegram_id: string; user_id: number }>();

  if (!users || users.length === 0) {
    console.log('마이그레이션 대상 없음');
    return result;
  }

  // 2. 각 사용자별 마이그레이션
  for (const user of users) {
    try {
      const tableName = `conv_${user.telegram_id}`;

      // 테이블 생성
      await db.exec(`
        CREATE TABLE IF NOT EXISTS ${tableName} (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
          content TEXT NOT NULL,
          tool_calls TEXT,
          tool_results TEXT,
          created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )
      `);

      await db.exec(`
        CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC)
      `);

      // 메시지 복사
      const { results: messages } = await db
        .prepare(`
          SELECT role, message as content, created_at
          FROM message_buffer
          WHERE user_id = ?
          ORDER BY created_at ASC
        `)
        .bind(user.user_id)
        .all<{ role: string; content: string; created_at: string }>();

      if (messages && messages.length > 0) {
        for (const msg of messages) {
          const role = msg.role === 'bot' ? 'assistant' : 'user';
          await db
            .prepare(`INSERT INTO ${tableName} (role, content, created_at) VALUES (?, ?, ?)`)
            .bind(role, msg.content, msg.created_at)
            .run();
        }

        result.migrated_messages += messages.length;
      }

      // 메타 테이블에 등록
      await db
        .prepare(`
          INSERT OR REPLACE INTO conversation_tables (telegram_id, table_name, message_count, last_message_at)
          VALUES (?, ?, ?, (SELECT MAX(created_at) FROM ${tableName}))
        `)
        .bind(user.telegram_id, tableName, messages?.length || 0)
        .run();

      result.migrated_users++;
      console.log(`마이그레이션 완료: ${user.telegram_id} (${messages?.length || 0}개)`);

    } catch (error) {
      const errMsg = error instanceof Error ? error.message : String(error);
      result.errors.push(`${user.telegram_id}: ${errMsg}`);
      console.error(`마이그레이션 실패: ${user.telegram_id}`, error);
    }
  }

  console.log('전체 마이그레이션 결과:', result);
  return result;
}

Step 2: Commit

git add scripts/migrate-conversations.ts
git commit -m "feat: add migration script for existing conversations"

Task 10: 테스트 및 배포

Step 1: 로컬 테스트

# 1. 마이그레이션 SQL 적용 (로컬)
wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql

# 2. 로컬 서버 실행
npm run dev

# 3. 테스트 메시지 전송 후 /history, /search, /stats 명령어 확인

Step 2: 타입 체크 & 빌드

npm run typecheck
npm run build  # 또는 wrangler deploy --dry-run

Expected: PASS

Step 3: 프로덕션 마이그레이션 적용

wrangler d1 execute telegram-conversations --file=migrations/008_conversation_tables.sql

Step 4: 배포

npm run deploy

Step 5: Commit (최종)

git add .
git commit -m "feat: complete conversation storage system implementation"

완료 후 검증 체크리스트

  • /history 명령어가 최근 대화를 표시하는가
  • /search 키워드 명령어가 관련 대화를 찾는가
  • /stats 명령어가 통계를 표시하는가
  • 새 메시지가 conv_{telegram_id} 테이블에 저장되는가
  • AI 응답이 스마트 컨텍스트를 사용하는가
  • 기존 기능 (명령어, 도메인, 서버, 예치금)이 정상 동작하는가

롤백 계획

  1. 코드 롤백: conversation-service.ts에서 saveConversationMessage 호출 제거
  2. DB 유지: 새 테이블들은 독립적이므로 삭제 불필요
  3. 기존 시스템: message_buffer + summaries가 그대로 작동