Files
variet-agent/api/discord_bot.py
CD 889badc52b feat: Git/Vikunja 미설정 시 차단 대신 안내 후 로컬 작업 진행
- 작업 실행 차단 제거 -> 안내 메시지만 표시
- /workspace set만으로 바로 작업 가능
- 나중에 /workspace git, /workspace vikunja로 등록 가능
2026-03-06 21:47:24 +09:00

605 lines
23 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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":
# Git/Vikunja 미설정 안내 (차단하지 않음)
if not ws.is_ready:
missing = ws.missing_configs
note = " / ".join(missing)
await message.channel.send(
f" {note} 미설정 상태입니다. 로컬 작업만 진행됩니다."
)
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()