Files
variet-agent/api/discord_bot.py
CD 752d851f9f feat: Pipeline 전면 개선 — 병렬실행, Batch Review, 총평, 대화기억, 스마트라우팅
- GeminiCaller: cmd/c 제거, 인자 분리, Semaphore(4) 동시성 제어, GeminiCallError
- TaskPipeline: asyncio.gather 병렬 코딩, batch_review 1회, summarize 총평
- FileApplier: Coder 출력 파싱 → 실제 파일 적용 (경로 보안 체크)
- Discord Bot: on_message 자동채팅, 의도분류(chat/task/clarify), 대화기억(10메시지)
- Prompts: router.md (의도분류), summarizer.md (총평)
- Workflows: agent_chat 환경 경로 업데이트
2026-03-06 20:46:58 +09:00

486 lines
18 KiB
Python

"""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()