Files
variet-agent/api/discord_bot.py
CD e65e2250a3 feat: Planner direct 모드 + Reviewer 검증
- direct 모드: Planner 직접 처리 → Reviewer 검증 → 완료 (2단계)
- tasks 모드: 기존대로 full pipeline (5단계)
- 대화 히스토리 embed 내용 포함 (clarify 맥락 유지)
2026-03-07 01:26:32 +09:00

836 lines
32 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")
EMBED_DESC_LIMIT = 4096
EMBED_FIELD_LIMIT = 1024
async def safe_send_embed(channel, embed: discord.Embed):
"""Embed가 Discord 제한을 초과하면 나눠서 전송."""
# description이 길면 분할
desc = embed.description or ""
if len(desc) <= EMBED_DESC_LIMIT:
await channel.send(embed=embed)
return
# 첫 번째: 원래 embed + 잘린 description
chunks = [desc[i:i+EMBED_DESC_LIMIT] for i in range(0, len(desc), EMBED_DESC_LIMIT)]
embed.description = chunks[0]
await channel.send(embed=embed)
# 나머지: 연속 embed
for chunk in chunks[1:]:
cont = discord.Embed(description=chunk, color=embed.color)
await channel.send(embed=cont)
# 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()
# 실행 중인 작업 추적 (채널ID → asyncio.Task)
_running_tasks: dict[int, asyncio.Task] = {}
# ──────────────────────────────────────────────
# 대화 기억
# ──────────────────────────────────────────────
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"
# 텍스트 내용
content = msg.content[:300] if msg.content else ""
# Embed 내용도 포함 (봇의 clarify 질문 등)
if msg.embeds:
embed_parts = []
for embed in msg.embeds:
if embed.title:
embed_parts.append(embed.title)
if embed.description:
embed_parts.append(embed.description[:200])
for field in embed.fields:
embed_parts.append(f"{field.name}: {field.value[:100]}")
if embed_parts:
embed_text = " | ".join(embed_parts)
content = f"{content} {embed_text}".strip() if content else embed_text
if content:
messages.append(f"[{role}] {content}")
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:
for guild in bot.guilds:
bot.tree.copy_global_to(guild=guild)
synced = await bot.tree.sync(guild=guild)
logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료 (서버: {guild.name})")
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_guild_channel_update(before, after):
"""채널 이름 변경 감지 -> 워크스페이스 자동 이름 변경."""
if before.name == after.name:
return
if not ws_manager.is_workspace_channel(after.id):
return
ws = ws_manager.get_workspace(after.id)
old_name = ws.name
new_name = after.name
success, new_path = ws_manager.rename_workspace(after.id, new_name)
if success:
embed = discord.Embed(
title="📝 워크스페이스 자동 업데이트",
description=(
f"채널 이름 변경 감지: `{old_name}` -> `{new_name}`\n\n"
f"워크스페이스 이름: **{new_name}**\n"
f"경로: `{new_path}`"
),
color=0x3498DB,
)
await after.send(embed=embed)
else:
logger.warning(f"채널 이름 변경 시 워크스페이스 업데이트 실패: {new_path}")
@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
# 취소 명령어 확인
cancel_keywords = {"취소", "stop", "cancel", "중지", "멈춰"}
if user_text.lower() in cancel_keywords:
channel_id = message.channel.id
if channel_id in _running_tasks and not _running_tasks[channel_id].done():
_running_tasks[channel_id].cancel()
del _running_tasks[channel_id]
await message.reply(
embed=discord.Embed(
title="🛑 작업 취소됨",
description="실행 중인 작업을 취소했습니다.",
color=0xE74C3C,
)
)
else:
await message.reply("실행 중인 작업이 없습니다.")
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} 미설정 상태입니다. 로컬 작업만 진행됩니다."
)
# 작업을 추적 가능한 Task로 실행
channel_id = message.channel.id
if channel_id in _running_tasks and not _running_tasks[channel_id].done():
await message.reply("⚠️ 이미 작업이 실행 중입니다. `취소` 후 다시 요청하세요.")
return
async def _tracked_task():
try:
await _handle_task(message, user_text, ws)
except asyncio.CancelledError:
await message.channel.send(
embed=discord.Embed(
title="🛑 작업 취소됨",
description="작업이 사용자에 의해 취소되었습니다.",
color=0xE74C3C,
)
)
finally:
_running_tasks.pop(channel_id, None)
task = asyncio.create_task(_tracked_task())
_running_tasks[channel_id] = task
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, MAX_REVIEW_RETRIES
pipeline = TaskPipeline(
project_path=ws.path,
docs_subpath=ws.docs_path,
)
pipeline.setup()
# 1. Plan (direct 모드면 Planner가 직접 처리)
embed.title = "🔍 분석 중..."
embed.color = 0xF39C12
await status_msg.edit(embed=embed)
plan = await pipeline.plan(text)
# ── Direct 모드: Planner가 직접 처리 완료 ──
is_direct = plan.get("direct", False)
if isinstance(is_direct, str):
is_direct = is_direct.lower() in ("true", "yes")
if is_direct:
result_text = plan.get("result", plan.get("summary", "완료"))
direct_embed = discord.Embed(
title="" + plan.get("summary", "처리 완료"),
description=result_text[:2000],
color=0x2ECC71,
)
await safe_send_embed(message.channel, direct_embed)
# Reviewer 검증
review_embed = discord.Embed(
title="🔍 리뷰어 검토 중...",
color=0xF39C12,
)
review_msg = await message.channel.send(embed=review_embed)
direct_tasks = [{"title": plan.get("summary", "직접 처리"), "description": result_text}]
review = await pipeline.batch_review(direct_tasks, [result_text])
passed = review.get("passed", True)
if isinstance(passed, str):
passed = passed.lower() in ("true", "yes", "pass")
review_embed.title = f"{'' if passed else '⚠️'} 리뷰 결과"
review_embed.description = review.get("summary", str(review))[:500]
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
review_embed.set_footer(text=f"ID: {task_id} | {ws.name} | direct")
await review_msg.edit(embed=review_embed)
pipeline.docs.record_session(text, {"summary": result_text}, plan)
return
# ── Tasks 모드: Coder에게 분배 ──
tasks = plan.get("tasks", [])
if tasks:
task_list = "\n".join(
f"{t.get('title', t.get('description', '?'))}"
for t in tasks[:10]
)
plan_embed = discord.Embed(
title="📝 작업 계획",
description=plan.get("summary", "")[:500],
color=0x2ECC71,
)
plan_embed.add_field(
name=f"태스크 ({len(tasks)}개)", value=task_list[:1000], inline=False,
)
await message.channel.send(embed=plan_embed)
else:
await message.channel.send(
embed=discord.Embed(
title="⚠️ 실행할 태스크 없음",
description="요청을 더 구체적으로 해주세요.",
color=0xF39C12,
)
)
return
# 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증)
MAX_PLANNER_LOOPS = 3 # Planner 내부 자가검증 반복 제한
review = None
all_code_outputs = []
for review_attempt in range(1 + MAX_REVIEW_RETRIES):
review_label = f" (리뷰 재시도 {review_attempt})" if review_attempt > 0 else ""
# 리뷰어 반려 시: 피드백으로 재계획
if review_attempt > 0:
feedback = review.get("summary", str(review))
await message.channel.send(
embed=discord.Embed(
title=f"🔄 리뷰어 피드백 반영 재계획",
description=feedback[:500],
color=0xE74C3C,
)
)
replan_request = (
f"## 원래 요청\n{text}\n\n"
f"## 리뷰어 피드백 (반드시 반영)\n{feedback}\n\n"
f"피드백을 분석하고 태스크를 재설계하세요."
)
plan = await pipeline.plan(replan_request)
tasks = plan.get("tasks", [])
if not tasks:
break
# ── Planner 내부 루프: 계획 → 코딩 → 자가검증 → 추가작업 ──
for planner_round in range(MAX_PLANNER_LOOPS):
round_label = f" (보완 {planner_round})" if planner_round > 0 else ""
# 코딩
code_embed = discord.Embed(
title=f"⚙️ 코딩 중...{review_label}{round_label} ({len(tasks)}개)",
description="\n".join(
f"{t.get('title', '?')[:60]}" for t in tasks[:10]
),
color=0xE67E22,
)
code_msg = await message.channel.send(embed=code_embed)
code_outputs = await pipeline.code_parallel(tasks)
all_code_outputs = code_outputs # 최신 결과 유지
error_count = sum(1 for o in code_outputs if o.startswith("[ERROR]"))
code_embed.title = f"✅ 코딩 완료{round_label} ({len(tasks) - error_count}/{len(tasks)})"
code_embed.color = 0x2ECC71 if error_count == 0 else 0xF39C12
await code_msg.edit(embed=code_embed)
# Planner 자가검증
verify_embed = discord.Embed(
title="🔍 Planner 자가 검증 중...",
color=0xF39C12,
)
verify_msg = await message.channel.send(embed=verify_embed)
verification = await pipeline.planner_verify(text, plan, code_outputs)
satisfied = verification.get("satisfied", True)
if isinstance(satisfied, str):
satisfied = satisfied.lower() in ("true", "yes")
verify_embed.title = f"{'' if satisfied else '🔄'} Planner 검증{round_label}"
verify_embed.description = verification.get("feedback", "")[:500]
verify_embed.color = 0x2ECC71 if satisfied else 0xF39C12
await verify_msg.edit(embed=verify_embed)
if satisfied:
break # Planner 만족 → 리뷰어에게 전달
# 추가 태스크가 있으면 계속
additional = verification.get("additional_tasks", [])
if additional:
tasks = additional
task_list = "\n".join(f"{t.get('title', '?')}" for t in tasks[:10])
await message.channel.send(
embed=discord.Embed(
title=f"📝 추가 작업 {len(additional)}",
color=0xF39C12,
).add_field(name="태스크", value=task_list[:1000], inline=False)
)
else:
break # 추가 태스크 없으면 종료
# ── 외부 리뷰어 ──
review_embed = discord.Embed(
title="🔍 리뷰어 검토 중...",
color=0xF39C12,
)
review_msg = await message.channel.send(embed=review_embed)
review = await pipeline.batch_review(tasks, all_code_outputs)
passed = review.get("passed", True)
if isinstance(passed, str):
passed = passed.lower() in ("true", "yes", "pass")
review_embed.title = f"{'' if passed else '⚠️'} 리뷰어 결과{review_label}"
review_embed.description = review.get("summary", str(review))[:500]
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
await review_msg.edit(embed=review_embed)
if passed:
break
# 3. 총평
summary = await pipeline.summarize(text, plan, all_code_outputs, review)
summary_embed = discord.Embed(
title=f"📊 {summary.get('title', '작업 완료')}",
description=summary.get("summary", "완료"),
color=0x9B59B6,
)
for field_name, key in [
("변경 사항", "changes"),
("⚠️ 주의", "warnings"),
("🔜 다음 단계", "next_steps"),
]:
items = summary.get(key, [])
if items:
if key == "changes" and isinstance(items[0], dict):
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 safe_send_embed(message.channel, summary_embed)
# 기록
pipeline.docs.record_session(text, summary, plan)
pipeline.docs.append_changelog(summary.get("title", text[:50]))
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
# 이름 미입력 시 채널 이름 사용
if not name:
name = interaction.channel.name
# 이름 충돌 검사
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()