"""Discord Bot 어댑터. 사용자 명령을 받아 파이프라인 실행 또는 즉답. 지정 채널(봇 이름 채널)에서는 접두사 없이 자연스럽게 대화합니다. 대화 기억: 채널별 최근 메시지를 컨텍스트로 주입. """ import asyncio import json import logging import re import discord from discord.ext import commands import config from core.gemini_caller import GeminiCallError logger = logging.getLogger("variet.discord") # Bot 설정 intents = discord.Intents.default() intents.message_content = True bot = commands.Bot( command_prefix=config.DISCORD_COMMAND_PREFIX, intents=intents, help_command=commands.DefaultHelpCommand(no_category="명령어"), ) # In-memory _channel_tasks: dict[int, list[str]] = {} _auto_chat_channel_ids: set[int] = set() # ────────────────────────────────────────────── # 대화 기억 (Conversation Memory) # ────────────────────────────────────────────── async def _get_channel_history(channel: discord.TextChannel, limit: int = 10) -> str: """채널의 최근 메시지를 대화 히스토리 문자열로 변환.""" messages = [] async for msg in channel.history(limit=limit + 1): # 현재 메시지 포함이므로 +1 if msg.author.bot: role = "assistant" else: role = "user" messages.append(f"[{role}] {msg.content[:300]}") # 시간순 (오래된 것 먼저) messages.reverse() # 마지막(현재 메시지)은 제외 — 이미 context로 전달되니까 if messages: messages = messages[:-1] if not messages: return "" return "=== CONVERSATION HISTORY ===\n" + "\n".join(messages) + "\n\n" # ────────────────────────────────────────────── # 의도 분류 (Intent Router) # ────────────────────────────────────────────── async def _classify_intent(message: str) -> dict: """Gemini로 사용자 메시지의 의도를 분류.""" from core.gemini_caller import GeminiCaller gemini = GeminiCaller(str(config.PROJECT_ROOT)) try: raw = await gemini.call("router", message, timeout=30) except GeminiCallError as e: logger.warning(f"Intent 분류 실패: {e}") return {"intent": "chat", "reason": "분류 실패"} try: json_match = re.search(r'\{[^}]+\}', raw) if json_match: return json.loads(json_match.group()) except (json.JSONDecodeError, AttributeError): pass logger.warning(f"Intent JSON 파싱 실패, chat으로 처리: {raw[:100]}") return {"intent": "chat", "reason": "파싱 실패 — 기본 chat"} # ────────────────────────────────────────────── # 이벤트 핸들러 # ────────────────────────────────────────────── @bot.event async def on_ready(): """봇 접속 완료 — 봇 이름 채널 자동 감지.""" logger.info(f"Discord Bot 접속 완료: {bot.user} (ID: {bot.user.id})") logger.info(f"서버 {len(bot.guilds)}개 연결됨") bot_name = bot.user.name.lower().replace(" ", "-") bot_name_underscore = bot_name.replace("-", "_") bot_name_dash = bot_name.replace("_", "-") for guild in bot.guilds: for channel in guild.text_channels: ch_name = channel.name.lower() if ch_name in (bot_name, bot_name_underscore, bot_name_dash): _auto_chat_channel_ids.add(channel.id) logger.info( f"자동 채팅 채널 감지: #{channel.name} (ID: {channel.id}) " f"in {guild.name}" ) if not _auto_chat_channel_ids: logger.info(f"봇 이름({bot_name}) 채널 없음 — !명령어만 사용 가능") await bot.change_presence( activity=discord.Activity( type=discord.ActivityType.listening, name="대화" if _auto_chat_channel_ids else f"{config.DISCORD_COMMAND_PREFIX}agent", ) ) @bot.event async def on_message(message: discord.Message): """모든 메시지 수신 — 봇 전용 채널이면 스마트 라우팅.""" if message.author == bot.user or message.author.bot: return # ! 명령어는 기존 핸들러로 if message.content.startswith(config.DISCORD_COMMAND_PREFIX): await bot.process_commands(message) return # 봇 전용 채널이 아니면 무시 if message.channel.id not in _auto_chat_channel_ids: return await _route_message(message) # ────────────────────────────────────────────── # 스마트 라우팅 # ────────────────────────────────────────────── async def _route_message(message: discord.Message): """메시지 의도를 분류하고 적절한 핸들러로 라우팅.""" user_text = message.content.strip() if not user_text: return # 짧은 메시지 → 분류 없이 바로 chat if len(user_text) <= 15: await _handle_chat(message, user_text) return # 의도 분류 async with message.channel.typing(): intent_result = await _classify_intent(user_text) intent = intent_result.get("intent", "chat") reason = intent_result.get("reason", "") logger.info(f"의도 분류: {intent} ({reason}) — \"{user_text[:50]}\"") if intent == "task": await _handle_task(message, user_text) elif intent == "clarify": await _handle_clarify(message, user_text, reason) else: await _handle_chat(message, user_text) # ────────────────────────────────────────────── # 핸들러: Chat (즉답 + 대화 기억) # ────────────────────────────────────────────── async def _handle_chat(message: discord.Message, text: str): """즉답 — 대화 히스토리 포함하여 Gemini 직접 호출.""" async with message.channel.typing(): try: from core.gemini_caller import GeminiCaller # 대화 기억: 최근 10개 메시지 history = await _get_channel_history(message.channel, limit=10) context = f"{history}{text}" gemini = GeminiCaller(str(config.PROJECT_ROOT)) response = await gemini.call("default", context, timeout=60) if not response: await message.reply("⚠️ 응답을 생성하지 못했어요. 다시 시도해 주세요.") return # Discord 2000자 제한 if len(response) <= 2000: await message.reply(response) else: for i in range(0, len(response), 4000): chunk = response[i:i + 4000] embed = discord.Embed(description=chunk, color=0x3498DB) await message.channel.send(embed=embed) except GeminiCallError as e: logger.error(f"Chat Gemini 오류: {e}") await message.reply(f"⚠️ AI 호출 오류: {str(e)[:200]}") except Exception as e: logger.error(f"Chat 오류: {e}", exc_info=True) await message.reply(f"❌ 오류: {str(e)[:200]}") # ────────────────────────────────────────────── # 핸들러: Task (파이프라인 실행) # ────────────────────────────────────────────── async def _handle_task(message: discord.Message, text: str): """작업 요청 — 새 파이프라인 (병렬 Code + Batch Review + 총평).""" import uuid task_id = uuid.uuid4().hex[:8] # 접수 메시지 embed = discord.Embed( title="📋 작업 접수", description=f"```{text[:200]}```", color=0x3498DB, ) embed.set_footer(text=f"ID: {task_id} — 분석 중...") status_msg = await message.channel.send(embed=embed) try: from core.task_pipeline import TaskPipeline pipeline = TaskPipeline(project_path=str(config.PROJECT_ROOT)) pipeline.setup() # ── Plan ── embed.color = 0xF39C12 embed.set_footer(text=f"🔍 작업 분해 중... (ID: {task_id})") await status_msg.edit(embed=embed) plan = await pipeline.plan(text) tasks = plan.get("tasks", []) plan_text = plan.get("summary", str(plan))[:500] plan_embed = discord.Embed( title="📝 작업 계획", description=f"```{plan_text}```", color=0x2ECC71, ) if tasks: task_list = "\n".join( f"• {t.get('title', t.get('description', '?'))}" for t in tasks[:10] ) plan_embed.add_field( name=f"태스크 ({len(tasks)}개)", value=task_list[:1000], inline=False, ) plan_embed.set_footer(text=f"ID: {task_id}") await message.channel.send(embed=plan_embed) if not tasks: await message.channel.send( embed=discord.Embed( title="⚠️ 실행할 태스크가 없습니다", description="요청을 더 구체적으로 해주세요.", color=0xF39C12, ) ) return # ── Code 병렬 실행 ── code_embed = discord.Embed( title=f"⚙️ 코딩 중... ({len(tasks)}개 병렬 실행)", description="\n".join( f"• {t.get('title', t.get('description', '?'))[:60]}" for t in tasks ), color=0xE67E22, ) code_msg = await message.channel.send(embed=code_embed) code_outputs = await pipeline.code_parallel(tasks) # 코딩 완료 표시 code_embed.title = f"✅ 코딩 완료 ({len(tasks)}개)" code_embed.color = 0x2ECC71 await code_msg.edit(embed=code_embed) # ── 파일 적용 ── from core.file_applier import parse_code_output, apply_changes all_applied = [] for output in code_outputs: if not output.startswith("[ERROR]"): changes = parse_code_output(output) if changes: applied = apply_changes(changes, str(config.PROJECT_ROOT)) all_applied.extend(applied) if all_applied: files_text = "\n".join( f"• `{f['path']}` ({f['action']})" for f in all_applied[:15] ) await message.channel.send( embed=discord.Embed( title=f"📁 파일 적용 ({len(all_applied)}개)", description=files_text, color=0x3498DB, ) ) # ── Batch Review ── review_msg = await message.channel.send( embed=discord.Embed( title="🔍 전체 리뷰 중...", color=0xF39C12, ) ) review = await pipeline.batch_review(tasks, code_outputs) passed = review.get("passed", True) review_embed = discord.Embed( title=f"{'✅' if passed else '⚠️'} 리뷰 결과", description=review.get("summary", str(review))[:500], color=0x2ECC71 if passed else 0xE74C3C, ) await review_msg.edit(embed=review_embed) # ── 총평 ── summary = await pipeline.summarize( text, plan, code_outputs, review, all_applied ) summary_embed = discord.Embed( title=f"📊 {summary.get('title', '작업 완료')}", description=summary.get("summary", "작업이 완료되었습니다."), color=0x9B59B6, ) # 변경 파일 요약 changes = summary.get("changes", []) if changes: changes_text = "\n".join( f"• `{c.get('file', '?')}` — {c.get('description', '')}" for c in changes[:10] ) summary_embed.add_field( name="변경 사항", value=changes_text[:1000], inline=False, ) # 주의사항 warnings = summary.get("warnings", []) if warnings: summary_embed.add_field( name="⚠️ 주의", value="\n".join(f"• {w}" for w in warnings), inline=False, ) # 다음 단계 제안 next_steps = summary.get("next_steps", []) if next_steps: summary_embed.add_field( name="🔜 다음 단계", value="\n".join(f"• {s}" for s in next_steps), inline=False, ) summary_embed.set_footer(text=f"ID: {task_id}") await message.channel.send(embed=summary_embed) _channel_tasks.setdefault(message.channel.id, []).append(task_id) except GeminiCallError as e: logger.error(f"파이프라인 Gemini 오류: {e}") await message.channel.send( embed=discord.Embed( title="❌ AI 호출 오류", description=f"```{str(e)[:500]}```", color=0xE74C3C, ) ) except Exception as e: logger.error(f"작업 실행 오류: {e}", exc_info=True) await message.channel.send( embed=discord.Embed( title="❌ 오류 발생", description=f"```{str(e)[:500]}```", color=0xE74C3C, ) ) # ────────────────────────────────────────────── # 핸들러: Clarify (되묻기) # ────────────────────────────────────────────── async def _handle_clarify(message: discord.Message, text: str, reason: str): """의도 불명확 — 사용자에게 되묻기.""" embed = discord.Embed( title="🤔 확인이 필요해요", description=( f"말씀하신 내용을 정확히 이해하고 싶어요.\n\n" f"> {text[:200]}\n\n" f"**💬 질문/대화**인가요, **🔧 작업 요청**인가요?\n" f"`질문` 또는 `작업`으로 답해주세요." ), color=0xF39C12, ) embed.set_footer(text=f"사유: {reason}") await message.reply(embed=embed) # ────────────────────────────────────────────── # 기존 ! 명령어 (유지) # ────────────────────────────────────────────── @bot.command(name="agent", help="AI Agent에게 작업 요청\n예: !agent README에 설치 방법 추가해줘") async def agent_command(ctx: commands.Context, *, request: str): """작업 요청 → Pipeline 실행.""" await _handle_task(ctx.message, request) @bot.command(name="chat", help="Gemini에게 질문/대화\n예: !chat 네 소개를 해줘") async def chat_command(ctx: commands.Context, *, message: str): """단순 대화.""" await _handle_chat(ctx.message, message) @bot.command(name="ping", help="봇 응답 테스트") async def ping_command(ctx: commands.Context): """연결 상태 확인.""" latency = round(bot.latency * 1000) await ctx.send(f"🏓 Pong! ({latency}ms)") @bot.command(name="info", help="시스템 정보") async def info_command(ctx: commands.Context): """시스템 정보 표시.""" embed = discord.Embed( title="🤖 Variet Agent", description="AI Agent Team — Gemini CLI 기반 자동화 개발 에이전트", color=0x9B59B6, ) embed.add_field(name="프로젝트", value=str(config.PROJECT_ROOT), inline=False) embed.add_field(name="명령어 접두사", value=config.DISCORD_COMMAND_PREFIX, inline=True) embed.add_field(name="서버 수", value=str(len(bot.guilds)), inline=True) if _auto_chat_channel_ids: channels = ", ".join(f"<#{ch_id}>" for ch_id in _auto_chat_channel_ids) embed.add_field(name="자동 채팅 채널", value=channels, inline=False) embed.add_field( name="파이프라인", value="Plan → Code(병렬) → Review(배치) → 총평", inline=False, ) await ctx.send(embed=embed) async def start_bot(): """Discord Bot 시작 (async).""" token = config.DISCORD_BOT_TOKEN if not token: logger.error("DISCORD_BOT_TOKEN이 설정되지 않았습니다. .env 파일을 확인하세요.") return logger.info("Discord Bot 시작 중...") try: await bot.start(token) except discord.LoginFailure: logger.error("Discord 로그인 실패 — 토큰을 확인하세요.") except Exception as e: logger.error(f"Discord Bot 오류: {e}") async def stop_bot(): """Discord Bot 정지.""" if not bot.is_closed(): await bot.close()