"""Discord Bot — 워크스페이스 기반 AI Agent. 슬래시 커맨드로 워크스페이스 관리. 등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답). """ import asyncio import json import logging import re import discord from discord import app_commands from discord.ext import commands import config from core.workspace import WorkspaceManager from core.gemini_caller import GeminiCaller, 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="명령어"), ) # 워크스페이스 매니저 (전역) ws_manager = WorkspaceManager() # ────────────────────────────────────────────── # 대화 기억 # ────────────────────────────────────────────── async def _get_channel_history(channel: discord.TextChannel, limit: int = 10) -> str: """채널 최근 메시지를 대화 히스토리 문자열로 변환.""" messages = [] async for msg in channel.history(limit=limit + 1): role = "assistant" if msg.author.bot else "user" messages.append(f"[{role}] {msg.content[:300]}") messages.reverse() if messages: messages = messages[:-1] # 현재 메시지 제외 if not messages: return "" return "=== CONVERSATION HISTORY ===\n" + "\n".join(messages) + "\n\n" # ────────────────────────────────────────────── # 통합 프롬프트 (1회 호출: 분류 + 응답/계획) # ────────────────────────────────────────────── async def _unified_call(text: str, history: str, project_path: str) -> dict: """통합 프롬프트로 1회 호출 — chat/task/clarify 자동 분기.""" gemini = GeminiCaller(project_path) # docs 인덱스 주입 from core.docs_manager import DocsManager docs = DocsManager(project_path) docs_index = docs.get_docs_index() context = ( f"{history}" f"## Project Docs\n{docs_index}\n\n" f"## User Message\n{text}" ) raw = await gemini.call("unified", context, timeout=120) # JSON 추출 try: # ```json ... ``` 패턴 match = re.search(r'```json\s*\n(.*?)\n\s*```', raw, re.DOTALL) if match: return json.loads(match.group(1)) # 중첩 { } 찾기 brace_depth = 0 start = -1 for i, ch in enumerate(raw): if ch == '{': if brace_depth == 0: start = i brace_depth += 1 elif ch == '}': brace_depth -= 1 if brace_depth == 0 and start >= 0: return json.loads(raw[start:i + 1]) except (json.JSONDecodeError, AttributeError): pass # 파싱 실패 → chat으로 처리 logger.warning(f"통합 프롬프트 JSON 파싱 실패: {raw[:100]}") return {"mode": "chat", "response": raw} # ────────────────────────────────────────────── # 이벤트 핸들러 # ────────────────────────────────────────────── @bot.event async def on_ready(): """봇 접속 완료 — 슬래시 커맨드 동기화.""" logger.info(f"Discord Bot 접속 완료: {bot.user} (ID: {bot.user.id})") logger.info(f"서버 {len(bot.guilds)}개 연결됨") # 슬래시 커맨드 동기화 try: synced = await bot.tree.sync() logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료") except Exception as e: logger.error(f"슬래시 커맨드 동기화 실패: {e}") # 유령 워크스페이스 정리 all_channel_ids = set() for guild in bot.guilds: for channel in guild.text_channels: all_channel_ids.add(channel.id) orphans = ws_manager.cleanup_orphans(all_channel_ids) if orphans: names = ", ".join(ws.name for ws in orphans) logger.info(f"유령 워크스페이스 {len(orphans)}개 정리: {names}") # 등록된 워크스페이스 표시 ws_list = ws_manager.list_all() if ws_list: for ws in ws_list: logger.info(f"워크스페이스 활성: #{ws.channel_id} -> {ws.name} ({ws.path})") else: logger.info("등록된 워크스페이스 없음 - /workspace set 으로 등록하세요") await bot.change_presence( activity=discord.Activity( type=discord.ActivityType.listening, name="/workspace", ) ) @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 not ws_manager.is_workspace_channel(message.channel.id): return ws = ws_manager.get_workspace(message.channel.id) user_text = message.content.strip() if not user_text: return # 통합 프롬프트 호출 async with message.channel.typing(): try: history = await _get_channel_history(message.channel, limit=10) result = await _unified_call(user_text, history, ws.path) except GeminiCallError as e: await message.reply(f"⚠️ AI 호출 오류: {str(e)[:200]}") return except Exception as e: logger.error(f"통합 호출 오류: {e}", exc_info=True) await message.reply(f"❌ 오류: {str(e)[:200]}") return mode = result.get("mode", "chat") logger.info(f"통합 분류: {mode} — \"{user_text[:50]}\"") if mode == "task": # 설정 완료 체크 if not ws.is_ready: await _send_setup_warning(message, ws) return await _handle_task(message, user_text, ws) elif mode == "clarify": question = result.get("question", "더 구체적으로 말씀해 주시겠어요?") embed = discord.Embed( title="🤔 확인이 필요해요", description=question, color=0xF39C12, ) await message.reply(embed=embed) else: # chat — 즉답 response = result.get("response", "응답을 생성하지 못했습니다.") 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) # ────────────────────────────────────────────── # 설정 경고 # ────────────────────────────────────────────── async def _send_setup_warning(message: discord.Message, ws): """미설정 항목 안내.""" missing = ws.missing_configs lines = [] for item in missing: if item == "Git": lines.append("❌ **Git** 미설정 → `/workspace git` 으로 설정") elif item == "Vikunja": lines.append("❌ **Vikunja** 미설정 → `/workspace vikunja` 으로 설정") embed = discord.Embed( title="⚠️ 워크스페이스 설정 미완료", description=( f"**{ws.name}** 워크스페이스의 설정이 완료되지 않아 작업을 실행할 수 없습니다.\n\n" + "\n".join(lines) + "\n\n설정 완료 후 다시 요청해주세요." ), color=0xE74C3C, ) await message.reply(embed=embed) # ────────────────────────────────────────────── # Task 핸들러 (파이프라인 실행) # ────────────────────────────────────────────── async def _handle_task(message: discord.Message, text: str, ws): """작업 요청 — 파이프라인 실행.""" 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} — 워크스페이스: {ws.name}") status_msg = await message.channel.send(embed=embed) try: from core.task_pipeline import TaskPipeline pipeline = TaskPipeline( project_path=ws.path, docs_subpath=ws.docs_path, ) 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_embed = discord.Embed( title="📝 작업 계획", description=f"```{plan.get('summary', str(plan))[:500]}```", 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, ) 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', '?')[: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, ws.path) 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 = await pipeline.batch_review(tasks, code_outputs) passed = review.get("passed", True) await message.channel.send( embed=discord.Embed( title=f"{'✅' if passed else '⚠️'} 리뷰 결과", description=review.get("summary", str(review))[:500], color=0x2ECC71 if passed else 0xE74C3C, ) ) # 총평 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, ) for field_name, key, emoji in [ ("변경 사항", "changes", ""), ("⚠️ 주의", "warnings", ""), ("🔜 다음 단계", "next_steps", ""), ]: items = summary.get(key, []) if items: if key == "changes": val = "\n".join(f"• `{c.get('file','?')}` — {c.get('description','')}" for c in items[:10]) else: val = "\n".join(f"• {s}" for s in items) summary_embed.add_field(name=field_name, value=val[:1000], inline=False) summary_embed.set_footer(text=f"ID: {task_id} | {ws.name}") await message.channel.send(embed=summary_embed) except GeminiCallError as 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) ) # ────────────────────────────────────────────── # 슬래시 커맨드: /workspace # ────────────────────────────────────────────── workspace_group = app_commands.Group(name="workspace", description="워크스페이스 관리") @workspace_group.command(name="set", description="이 채널에 워크스페이스 등록") @app_commands.describe(name="프로젝트 이름", path="로컬 경로 (미입력 시 VW_Proj/{name}에 자동 생성)") async def workspace_set(interaction: discord.Interaction, name: str, path: str = ""): """채널에 워크스페이스 등록.""" from pathlib import Path as P # 이름 충돌 검사 conflicts = ws_manager.find_by_name(name) if conflicts: old = conflicts[0] embed = discord.Embed( title="⚠️ 이름 충돌", description=( f"**{name}** 이름의 프로젝트가 이미 존재합니다.\n\n" f"기존 등록: 채널 <#{old.channel_id}>\n" f"경로: `{old.path}`\n\n" f"**선택지:**\n" f"1️⃣ 다른 이름으로 등록: `/workspace set name:새이름`\n" f"2️⃣ 기존 프로젝트를 삭제 후 재등록: 기존 채널에서 `/workspace remove`" ), color=0xF39C12, ) await interaction.response.send_message(embed=embed) return # 부모 경로 검증 (명시적 경로 지정 시) if path and not P(path).parent.exists(): await interaction.response.send_message( f"❌ 부모 경로가 존재하지 않습니다: `{path}`", ephemeral=True ) return ws = ws_manager.set_workspace(interaction.channel_id, name, path) embed = discord.Embed( title="✅ 워크스페이스 등록 완료", description=( f"**{name}** -> `{ws.path}`\n\n" f"이 채널에서 봇과 대화할 수 있습니다.\n" f"작업 실행을 위해 Git과 Vikunja를 설정해주세요:" ), color=0x2ECC71, ) embed.add_field(name="Git 설정", value="`/workspace git`", inline=True) embed.add_field(name="Vikunja 설정", value="`/workspace vikunja`", inline=True) await interaction.response.send_message(embed=embed) @workspace_group.command(name="git", description="Git 연결 설정") @app_commands.describe(url="Git 서버 URL", token="API 토큰", repo="Owner/Repo", branch="기본 브랜치") async def workspace_git(interaction: discord.Interaction, url: str, token: str, repo: str = "", branch: str = "main"): """워크스페이스에 Git 설정.""" ws = ws_manager.get_workspace(interaction.channel_id) if not ws: await interaction.response.send_message( "❌ 이 채널에 워크스페이스가 없습니다. `/workspace set`을 먼저 실행하세요.", ephemeral=True, ) return ws_manager.set_git(interaction.channel_id, url, token, repo, branch) embed = discord.Embed( title="✅ Git 연결 완료", description=f"**{ws.name}** → {url}\nRepo: `{repo or '미지정'}`\nBranch: `{branch}`", color=0x2ECC71, ) await interaction.response.send_message(embed=embed) @workspace_group.command(name="vikunja", description="Vikunja 프로젝트 관리 연결") @app_commands.describe(url="Vikunja URL", token="API 토큰", project_id="프로젝트 ID") async def workspace_vikunja(interaction: discord.Interaction, url: str, token: str, project_id: int): """워크스페이스에 Vikunja 설정.""" ws = ws_manager.get_workspace(interaction.channel_id) if not ws: await interaction.response.send_message( "❌ 이 채널에 워크스페이스가 없습니다. `/workspace set`을 먼저 실행하세요.", ephemeral=True, ) return ws_manager.set_vikunja(interaction.channel_id, url, token, project_id) embed = discord.Embed( title="✅ Vikunja 연결 완료", description=f"**{ws.name}** → {url}\nProject ID: `{project_id}`", color=0x2ECC71, ) await interaction.response.send_message(embed=embed) @workspace_group.command(name="info", description="현재 워크스페이스 정보 표시") async def workspace_info(interaction: discord.Interaction): """현재 채널 워크스페이스 상태.""" ws = ws_manager.get_workspace(interaction.channel_id) if not ws: await interaction.response.send_message( "이 채널에 등록된 워크스페이스가 없습니다.\n`/workspace set`으로 등록하세요.", ephemeral=True, ) return git_status = f"✅ {ws.git.url}" if ws.git.is_configured else "❌ 미설정" vik_status = f"✅ {ws.vikunja.url} (project {ws.vikunja.project_id})" if ws.vikunja.is_configured else "❌ 미설정" embed = discord.Embed( title=f"📂 {ws.name}", description=f"경로: `{ws.path}`\nDocs: `{ws.docs_path}`", color=0x3498DB if ws.is_ready else 0xF39C12, ) embed.add_field(name="Git", value=git_status, inline=False) embed.add_field(name="Vikunja", value=vik_status, inline=False) embed.add_field( name="상태", value="✅ 모든 설정 완료 — 작업 가능" if ws.is_ready else "⚠️ 설정 미완료 — 작업 차단됨", inline=False, ) await interaction.response.send_message(embed=embed) @workspace_group.command(name="remove", description="워크스페이스 등록 해제") async def workspace_remove(interaction: discord.Interaction): """워크스페이스 제거.""" ws = ws_manager.get_workspace(interaction.channel_id) if not ws: await interaction.response.send_message( "이 채널에 등록된 워크스페이스가 없습니다.", ephemeral=True ) return name = ws.name ws_manager.remove_workspace(interaction.channel_id) await interaction.response.send_message( embed=discord.Embed( title="🗑️ 워크스페이스 해제", description=f"**{name}** 등록이 해제되었습니다.\n이 채널에서 봇이 더 이상 자동 응답하지 않습니다.", color=0x95A5A6, ) ) @workspace_group.command(name="list", description="등록된 전체 워크스페이스 목록") async def workspace_list(interaction: discord.Interaction): """전체 워크스페이스 목록.""" all_ws = ws_manager.list_all() if not all_ws: await interaction.response.send_message( "등록된 워크스페이스가 없습니다.\n`/workspace set`으로 등록하세요.", ephemeral=True, ) return embed = discord.Embed(title="📂 워크스페이스 목록", color=0x3498DB) for ws in all_ws: status = "✅" if ws.is_ready else "⚠️" embed.add_field( name=f"{status} {ws.name}", value=f"채널: <#{ws.channel_id}>\n경로: `{ws.path}`", inline=False, ) await interaction.response.send_message(embed=embed) # 슬래시 커맨드 그룹 등록 bot.tree.add_command(workspace_group) # ────────────────────────────────────────────── # 기존 ! 명령어 (유지, 하위호환) # ────────────────────────────────────────────── @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 — 워크스페이스 기반 자동화 개발 에이전트", color=0x9B59B6, ) all_ws = ws_manager.list_all() embed.add_field(name="워크스페이스", value=f"{len(all_ws)}개 등록", inline=True) embed.add_field(name="서버", value=str(len(bot.guilds)), inline=True) embed.add_field( name="파이프라인", value="통합분류(1회) → Code(병렬) → Review(배치) → 총평 → 기록", inline=False, ) await ctx.send(embed=embed) async def start_bot(): """Discord Bot 시작.""" token = config.DISCORD_BOT_TOKEN if not token: logger.error("DISCORD_BOT_TOKEN이 설정되지 않았습니다.") 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()