Files
variet-agent/api/discord_bot.py

1216 lines
47 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.
"""Discord Bot — 워크스페이스 기반 AI Agent.
슬래시 커맨드로 워크스페이스 관리.
등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답).
/task 커맨드로 프로젝트 선택 → 스레드 자동 생성 → 스레드 내 작업.
"""
import asyncio
import json
import logging
import re
from datetime import datetime
from pathlib import Path
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
from core.foreman import Foreman
from handlers.nc_handler import NCHandler
logger = logging.getLogger("variet.discord")
# Nextcloud 도구 핸들러
_nc_handler = NCHandler()
# AI Foreman (목표 분해)
_foreman = Foreman()
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] = {}
# 스레드 ↔ 프로젝트 매핑
_project_threads: dict[str, int] = {} # 프로젝트명 → 활성 스레드 ID
_thread_workspaces: dict[int, "Workspace"] = {} # 스레드 ID → Workspace
# ──────────────────────────────────────────────
# 대화 기억
# ──────────────────────────────────────────────
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"
async def safe_send_embed(channel, embed: discord.Embed):
"""Embed 전송 (description 길이 초과 시 자동 분할)."""
desc = embed.description or ""
if len(desc) <= 4096:
await channel.send(embed=embed)
else:
# 분할 전송
for i in range(0, len(desc), 4000):
chunk_embed = discord.Embed(
title=embed.title if i == 0 else None,
description=desc[i:i+4000],
color=embed.color,
)
await channel.send(embed=chunk_embed)
# ──────────────────────────────────────────────
# 에이전트 호출 (MCP 도구 자동 사용)
# ──────────────────────────────────────────────
async def _agent_call(text: str, history: str, project_path: str) -> str:
"""Gemini CLI 에이전트 모드로 호출 — MCP 도구를 자율적으로 사용.
분류(chat/task/anime) 없이 에이전트가 직접 판단하여
도구를 사용하거나 바로 답변합니다.
"""
gemini = GeminiCaller(project_path)
context = (
f"{history}"
f"## Workspace\nPath: {project_path}\n\n"
f"## User Message\n{text}"
)
response = await gemini.call_agent(
"agent", context, cwd=project_path, timeout=1200,
)
return response
def _parse_unified_response(raw: str) -> dict:
"""Gemini unified prompt 응답에서 JSON 추출."""
import re as _re
# 1) ```json ... ``` 블록
m = _re.search(r"```json\s*\n(.+?)```", raw, _re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
# 2) 중괄호 균형 매칭으로 JSON 추출
start = raw.find("{")
if start != -1:
depth = 0
for i in range(start, len(raw)):
if raw[i] == "{":
depth += 1
elif raw[i] == "}":
depth -= 1
if depth == 0:
try:
return json.loads(raw[start:i + 1])
except json.JSONDecodeError:
break
# 3) 파싱 실패 → chat 모드 폴백
logger.warning(f"unified 응답 JSON 파싱 실패: {raw[:200]}")
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:
# 1) 글로벌 커맨드를 각 길드로 복사 + 동기화
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})")
# 2) 글로벌 커맨드 제거 (길드 커맨드와 중복 방지)
bot.tree.clear_commands(guild=None)
await bot.tree.sync()
logger.info("글로벌 슬래시 커맨드 정리 완료 (길드 전용)")
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_command_error(ctx, error):
"""존재하지 않는 ! 명령어 무시."""
if isinstance(error, commands.CommandNotFound):
return # 무시 — 로그 오염 방지
raise error
@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
# 워크스페이스 채널 또는 스레드 확인
ws = ws_manager.get_workspace(message.channel.id)
if not ws and message.channel.id in _thread_workspaces:
ws = _thread_workspaces[message.channel.id]
# 스레드의 부모 채널이 워크스페이스인 경우
if not ws and isinstance(message.channel, discord.Thread):
ws = ws_manager.get_workspace(message.channel.parent_id)
if not ws:
return
user_text = message.content.strip()
if not user_text:
return
# ──────────────────────────────────────
# Foreman 세션 스레드 확인
# ──────────────────────────────────────
foreman_session = _foreman.get_session(message.channel.id)
if foreman_session:
async def _foreman_reply():
try:
async with message.channel.typing():
# ! 명령어 처리
if user_text.startswith("!"):
parts = user_text[1:].split(maxsplit=1)
command = parts[0] if parts else ""
args = parts[1] if len(parts) > 1 else ""
response = await _foreman.handle_command(
foreman_session, command, args,
)
else:
# 자유 형식 대화
response = await _foreman.handle_freeform(
foreman_session, user_text,
)
if response:
if len(response) <= 2000:
await message.reply(response)
else:
for i in range(0, len(response), 4000):
embed = discord.Embed(
description=response[i:i + 4000],
color=0x9B59B6,
)
await message.channel.send(embed=embed)
except Exception as e:
logger.error(f"Foreman 오류: {e}", exc_info=True)
await message.reply(f"⚠️ 오류: {str(e)[:200]}")
asyncio.create_task(_foreman_reply())
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
# 통합 분류 → 라우팅 (unified prompt → NC handler / chat / agent)
channel_id = message.channel.id
if channel_id in _running_tasks and not _running_tasks[channel_id].done():
await message.reply("⚠️ 이미 작업이 실행 중입니다. `취소` 후 다시 요청하세요.")
return
async def _classify_and_route():
progress_msg = None
try:
progress_msg = await message.channel.send(
embed=discord.Embed(
title="🤖 처리 중...",
description=f"```{user_text[:200]}```",
color=0xF39C12,
)
)
async with message.channel.typing():
# 1단계: unified prompt로 분류
gemini = GeminiCaller()
history = await _get_channel_history(message.channel, limit=10)
classify_input = f"{history}## User Message\n{user_text}"
logger.info(f"[분류] 입력: {user_text[:80]}")
raw = await gemini.call("unified", classify_input, timeout=60)
logger.info(f"[분류] Gemini 출력 ({len(raw)}자): {raw[:200]}")
# JSON 파싱
parsed = _parse_unified_response(raw)
logger.info(f"[분류] 파싱 결과: {parsed}")
# 진행 메시지 삭제
if progress_msg:
try:
await progress_msg.delete()
except Exception:
pass
mode = parsed.get("mode", "chat")
logger.info(f"[라우팅] mode={mode}\"{user_text[:50]}\"")
# ── 라우팅 ──
if mode == "nextcloud":
# NC 핸들러로 직접 라우팅
logger.info(f"[NC] tool={parsed.get('tool')} op={parsed.get('op')} params={parsed.get('params')}")
await _nc_handler.handle(parsed, message.channel)
logger.info("[NC] handle 완료")
elif mode == "chat":
# 즉시 응답
response = parsed.get("response", "")
logger.info(f"[chat] 응답 길이: {len(response)}")
if response:
if len(response) <= 2000:
await message.reply(response)
else:
for i in range(0, len(response), 4000):
embed = discord.Embed(description=response[i:i + 4000], color=0x3498DB)
await message.channel.send(embed=embed)
else:
await message.reply("응답을 생성하지 못했습니다.")
elif mode == "clarify":
question = parsed.get("question", "무엇을 도와드릴까요?")
await message.reply(
embed=discord.Embed(
title="🤔 확인이 필요합니다",
description=question,
color=0xF39C12,
)
)
elif mode == "anime":
# 기존 anime 핸들러 호출
from handlers.anime_handler import handle_anime_message
await handle_anime_message(message, parsed)
elif mode == "task":
# 에이전트 모드 (파일 작업 필요)
logger.info("[task] 에이전트 호출 시작")
async with message.channel.typing():
response = await _agent_call(user_text, history, ws.path)
if response:
if len(response) <= 2000:
await message.reply(response)
else:
for i in range(0, len(response), 4000):
embed = discord.Embed(description=response[i:i + 4000], color=0x3498DB)
await message.channel.send(embed=embed)
else:
await message.reply("응답을 생성하지 못했습니다.")
else:
await message.reply(f"알 수 없는 모드: `{mode}`")
except asyncio.CancelledError:
await message.channel.send(
embed=discord.Embed(
title="🛑 작업 취소됨",
description="작업이 사용자에 의해 취소되었습니다.",
color=0xE74C3C,
)
)
except GeminiCallError as e:
logger.error(f"[분류] GeminiCallError: {e}")
await message.reply(f"⚠️ AI 호출 오류: {str(e)[:200]}")
except Exception as e:
logger.error(f"[분류/라우팅] 예외: {e}", exc_info=True)
await message.reply(f"❌ 오류: {str(e)[:200]}")
finally:
_running_tasks.pop(channel_id, None)
task = asyncio.create_task(_classify_and_route())
_running_tasks[channel_id] = task
# ──────────────────────────────────────────────
# 슬래시 커맨드: /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)
# ──────────────────────────────────────────────
# /task 커맨드 — 프로젝트 선택 + 스레드 생성
# ──────────────────────────────────────────────
class ProjectSelectView(discord.ui.View):
"""프로젝트 드롭다운 + 스레드 생성."""
def __init__(self, request_text: str):
super().__init__(timeout=60)
self.request_text = request_text
# 워크스페이스 목록으로 Select 옵션 구성 (channel_id를 value로 사용 — 고유 식별)
options = []
for ws in ws_manager.list_all():
label = ws.name[:100]
desc = ws.path[:100]
options.append(discord.SelectOption(label=label, description=desc, value=str(ws.channel_id)))
if not options:
options.append(discord.SelectOption(label="(등록된 프로젝트 없음)", value="__none__"))
select = discord.ui.Select(
placeholder="프로젝트를 선택하세요...",
options=options[:25], # Discord 제한
)
select.callback = self.on_select
self.add_item(select)
async def on_select(self, interaction: discord.Interaction):
selected_value = interaction.data["values"][0]
if selected_value == "__none__":
await interaction.response.send_message(
"❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.",
ephemeral=True,
)
return
# channel_id로 워크스페이스 직접 조회 (이름 충돌 방지)
ws = ws_manager.get_workspace(int(selected_value))
if not ws:
await interaction.response.send_message("❌ 워크스페이스를 찾을 수 없습니다.", ephemeral=True)
return
# 1) 활성 스레드가 이미 있는지 확인
if ws.name in _project_threads:
thread_id = _project_threads[ws.name]
try:
thread = interaction.guild.get_thread(thread_id)
if thread and not thread.archived:
# 기존 스레드에 요청 전달
await interaction.response.send_message(
f"📌 **{ws.name}** 프로젝트는 이미 열린 대화가 있습니다: <#{thread_id}>\n"
f"요청을 해당 스레드에 전달합니다.",
ephemeral=True,
)
# 스레드에 요청 메시지 전송
await thread.send(
f"📨 **새 요청** ({interaction.user.display_name}):\n```{self.request_text[:500]}```"
)
return
else:
# 스레드가 아카이브/삭제됨 → 매핑 정리
_project_threads.pop(ws.name, None)
_thread_workspaces.pop(thread_id, None)
except Exception:
_project_threads.pop(ws.name, None)
# 2) 기존 프로젝트 폴더가 있는지 확인 (충돌 체크)
project_path = Path(ws.path)
if project_path.exists() and any(project_path.iterdir()):
# 폴더에 내용물이 있음 → 충돌 해결 필요
view = ConflictView(ws, self.request_text, interaction)
await interaction.response.send_message(
embed=discord.Embed(
title=f"📂 {ws.name} — 기존 프로젝트 발견",
description=(
f"경로: `{ws.path}`\n\n"
f"기존 프로젝트를 이어가시겠습니까, 새로 시작하시겠습니까?"
),
color=0xF39C12,
),
view=view,
ephemeral=True,
)
return
# 3) 폴더 없거나 비어있음 → 바로 스레드 생성
await interaction.response.defer()
await _create_task_thread(interaction, ws, self.request_text)
class ConflictView(discord.ui.View):
"""기존 프로젝트 이어가기 / 새로 시작 선택."""
def __init__(self, ws, request_text: str, original_interaction: discord.Interaction):
super().__init__(timeout=60)
self.ws = ws
self.request_text = request_text
self.original_interaction = original_interaction
@discord.ui.button(label="🔄 이어가기", style=discord.ButtonStyle.primary)
async def continue_project(self, interaction: discord.Interaction, button: discord.ui.Button):
"""기존 프로젝트 폴더로 새 스레드 생성."""
await interaction.response.defer()
await _create_task_thread(interaction, self.ws, self.request_text)
self.stop()
@discord.ui.button(label="🆕 새로 시작", style=discord.ButtonStyle.secondary)
async def new_project(self, interaction: discord.Interaction, button: discord.ui.Button):
"""기존 폴더 아카이브 + 새 프로젝트 생성."""
# 기존 폴더 리네임
old_path = Path(self.ws.path)
suffix = f"_archived_{datetime.now().strftime('%Y%m%d')}"
new_archived_path = old_path.parent / f"{old_path.name}{suffix}"
counter = 1
while new_archived_path.exists():
new_archived_path = old_path.parent / f"{old_path.name}{suffix}_{counter}"
counter += 1
try:
old_path.rename(new_archived_path)
logger.info(f"프로젝트 아카이브: {old_path}{new_archived_path}")
except OSError as e:
logger.error(f"폴더 아카이브 실패: {e}")
await interaction.response.send_message(f"❌ 폴더 아카이브 실패: {e}", ephemeral=True)
return
# 아카이브된 프로젝트를 workspaces에 등록 (접근 유지)
archived_name = new_archived_path.name
archived_ws = ws_manager.set_workspace(
channel_id=-abs(hash(archived_name)) % (10**10),
name=archived_name,
path=str(new_archived_path),
)
logger.info(f"아카이브 워크스페이스 등록: {archived_name}")
# 새 폴더 생성
old_path.mkdir(parents=True, exist_ok=True)
await interaction.response.defer()
await _create_task_thread(interaction, self.ws, self.request_text)
self.stop()
async def _create_task_thread(
interaction: discord.Interaction,
ws,
request_text: str,
):
"""스레드를 생성하고 작업을 시작합니다."""
# 스레드 제목: 프로젝트명 + 요청 앞부분
thread_name = f"🔧 {ws.name}"
if request_text:
short_req = request_text[:40].replace("\n", " ")
thread_name = f"🔧 {ws.name}{short_req}"
thread_name = thread_name[:100] # Discord 제한
# 스레드 생성
channel = interaction.channel
thread = await channel.create_thread(
name=thread_name,
type=discord.ChannelType.public_thread,
auto_archive_duration=1440, # 24시간 후 자동 아카이브
)
# 매핑 등록
_project_threads[ws.name] = thread.id
_thread_workspaces[thread.id] = ws
logger.info(f"작업 스레드 생성: {thread.name} (ID: {thread.id}) → {ws.name}")
# 스레드에 시작 메시지
start_embed = discord.Embed(
title=f"📂 {ws.name}",
description=(
f"경로: `{ws.path}`\n\n"
f"**요청:** {request_text[:500]}\n\n"
f"이 스레드에서 대화를 이어갈 수 있습니다."
),
color=0x3498DB,
)
await thread.send(embed=start_embed)
# followup으로 스레드 안내
await interaction.followup.send(
f"✅ 스레드가 생성되었습니다: <#{thread.id}>",
ephemeral=True,
)
# 작업 실행 (가짜 Message 대신 스레드에 직접 메시지 전송)
if request_text.strip():
# 통합 프롬프트 호출
try:
async with thread.typing():
history = ""
response = await _agent_call(request_text, history, ws.path)
logger.info(f"[스레드] 에이전트 응답: \"{request_text[:50]}\" -> {len(response)}")
if response:
if len(response) <= 2000:
await thread.send(response)
else:
for i in range(0, len(response), 4000):
chunk = response[i:i + 4000]
embed = discord.Embed(description=chunk, color=0x3498DB)
await thread.send(embed=embed)
else:
await thread.send("응답을 생성하지 못했습니다.")
except Exception as e:
logger.error(f"스레드 초기 호출 오류: {e}", exc_info=True)
await thread.send(f"⚠️ 초기 호출 오류: {str(e)[:200]}")
@bot.tree.command(name="task", description="프로젝트를 선택하고 작업 요청")
@app_commands.describe(request="작업 요청 내용")
async def task_command(interaction: discord.Interaction, request: str = ""):
"""프로젝트 선택 드롭다운 → 스레드 생성 → 작업 시작."""
all_ws = ws_manager.list_all()
if not all_ws:
await interaction.response.send_message(
"❌ 등록된 프로젝트가 없습니다. `/workspace set`으로 먼저 등록하세요.",
ephemeral=True,
)
return
view = ProjectSelectView(request)
await interaction.response.send_message(
embed=discord.Embed(
title="📂 프로젝트 선택",
description=(
f"작업할 프로젝트를 선택하세요.\n"
+ (f"**요청:** {request[:200]}" if request else "선택 후 스레드에서 요청할 수 있습니다.")
),
color=0x3498DB,
),
view=view,
ephemeral=True,
)
# ──────────────────────────────────────────────
# 스레드 이벤트 — 아카이브/삭제 시 매핑 정리
# ──────────────────────────────────────────────
@bot.event
async def on_thread_update(before, after):
"""스레드 아카이브 감지 → 매핑 정리."""
if after.archived and after.id in _thread_workspaces:
ws = _thread_workspaces.pop(after.id)
_project_threads.pop(ws.name, None)
logger.info(f"스레드 아카이브 감지 → 매핑 제거: {ws.name} (스레드 {after.id})")
@bot.event
async def on_thread_delete(thread):
"""스레드 삭제 감지 → 매핑 정리."""
if thread.id in _thread_workspaces:
ws = _thread_workspaces.pop(thread.id)
_project_threads.pop(ws.name, None)
logger.info(f"스레드 삭제 감지 → 매핑 제거: {ws.name} (스레드 {thread.id})")
# ──────────────────────────────────────────────
# /anime 커맨드 — 애니메이션 자동화
# ──────────────────────────────────────────────
anime_group = app_commands.Group(name="anime", description="애니메이션 자막/영상 자동화")
@anime_group.command(name="search", description="애니 검색 (편성표 + 자막 + 토렌트)")
@app_commands.describe(title="검색할 애니 제목 (한글)")
async def anime_search(interaction: discord.Interaction, title: str):
"""Anissia + Nyaa 통합 검색."""
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
try:
result = await pipeline.search(title)
except Exception as e:
await interaction.followup.send(f"❌ 검색 오류: {e}")
return
if not result.anime:
await interaction.followup.send(f"'{title}' 검색 결과가 없습니다.")
return
anime = result.anime
embed = discord.Embed(
title=f"🔍 {anime.subject}",
description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}",
color=0x3498DB,
)
embed.add_field(
name="📅 편성",
value=f"{['','','','','','','','기타'][anime.week]}요일 {anime.time}",
inline=True,
)
embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True)
# 자막 정보
if result.captions:
cap_lines = []
for c in result.captions[:5]:
url_text = f"[사이트]({c.website})" if c.website else "URL 없음"
cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})")
embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False)
else:
embed.add_field(name="📝 자막", value="등록된 자막 없음", inline=False)
# 토렌트 정보
if result.torrents:
tor_lines = []
for t in result.torrents[:5]:
ep = f"**{t.episode}화**" if t.episode else ""
tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})")
embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False)
else:
embed.add_field(name="🎬 토렌트", value="검색 결과 없음", inline=False)
if result.errors:
embed.set_footer(text="⚠️ " + "; ".join(result.errors))
await interaction.followup.send(embed=embed)
@anime_group.command(name="download", description="자막+영상 자동 다운로드 (기본: 자막 있으면 영상도)")
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수 (없으면 최신)")
async def anime_download(interaction: discord.Interaction, title: str, episode: int = None):
"""자막+영상 자동 다운로드."""
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
embed = discord.Embed(title="⏳ 다운로드 진행 중...", description=f"**{title}**", color=0xF39C12)
msg = await interaction.followup.send(embed=embed, wait=True)
try:
result = await pipeline.download(title, mode="auto", episode=episode)
except Exception as e:
embed.title = "❌ 다운로드 오류"
embed.description = str(e)[:500]
embed.color = 0xE74C3C
await msg.edit(embed=embed)
return
embed.title = "✅ 다운로드 완료" if result.torrent_added or result.subtitles else "⚠️ 부분 완료"
embed.description = result.message[:4000]
embed.color = 0x2ECC71 if result.torrent_added or result.subtitles else 0xF39C12
await msg.edit(embed=embed)
@anime_group.command(name="sub", description="자막만 다운로드")
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수")
async def anime_sub(interaction: discord.Interaction, title: str, episode: int = None):
"""자막만 다운로드."""
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
try:
result = await pipeline.download(title, mode="sub_only", episode=episode)
except Exception as e:
await interaction.followup.send(f"❌ 오류: {e}")
return
embed = discord.Embed(
title=f"📝 자막 다운로드 {'완료' if result.subtitles else '실패'}",
description=result.message[:4000],
color=0x2ECC71 if result.subtitles else 0xE74C3C,
)
await interaction.followup.send(embed=embed)
@anime_group.command(name="video", description="영상만 다운로드 (자막 없어도 강제)")
@app_commands.describe(title="애니 제목 (한글)", episode="특정 화수")
async def anime_video(interaction: discord.Interaction, title: str, episode: int = None):
"""영상만 다운로드 (자막 체크 무시)."""
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
try:
result = await pipeline.download(title, mode="video_only", episode=episode)
except Exception as e:
await interaction.followup.send(f"❌ 오류: {e}")
return
embed = discord.Embed(
title=f"🎬 영상 다운로드 {'추가됨' if result.torrent_added else '실패'}",
description=result.message[:4000],
color=0x2ECC71 if result.torrent_added else 0xE74C3C,
)
await interaction.followup.send(embed=embed)
@anime_group.command(name="status", description="현재 다운로드 큐 상태")
async def anime_status(interaction: discord.Interaction):
"""qBittorrent 다운로드 상태 확인."""
await interaction.response.defer()
from tools.anime_pipeline import AnimePipeline
pipeline = AnimePipeline()
# 연결 테스트
conn = await pipeline.qbit.test_connection()
if not conn.get("connected"):
await interaction.followup.send(
embed=discord.Embed(
title="❌ qBittorrent 연결 실패",
description=f"URL: `{conn.get('url')}`\n오류: {conn.get('error', '?')}",
color=0xE74C3C,
)
)
return
torrents = await pipeline.get_status()
embed = discord.Embed(
title=f"📊 다운로드 큐 ({len(torrents)}건)",
description=f"qBittorrent {conn.get('version', '?')} | API {conn.get('api_version', '?')}",
color=0x3498DB,
)
if torrents:
for t in torrents[:10]:
status_icon = "" if t["progress"] == "100.0%" else ""
embed.add_field(
name=f"{status_icon} {t['name'][:50]}",
value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}",
inline=False,
)
else:
embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False)
await interaction.followup.send(embed=embed)
bot.tree.add_command(anime_group)
# ──────────────────────────────────────────────
# /goal 커맨드 — AI Foreman 목표 분해
# ──────────────────────────────────────────────
@bot.tree.command(name="goal", description="목표를 입력하면 AI가 작업 트리로 분해합니다")
@app_commands.describe(goal="달성할 목표 (자연어)")
async def goal_command(interaction: discord.Interaction, goal: str):
"""Foreman 상담 모드 시작."""
await interaction.response.defer()
# 스레드 생성
thread_name = f"🎯 {goal[:80]}"
thread = await interaction.channel.create_thread(
name=thread_name,
type=discord.ChannelType.public_thread,
auto_archive_duration=1440,
)
# 세션 생성
session = _foreman.create_session(goal, thread.id, interaction.user.id)
# 시작 메시지
start_embed = discord.Embed(
title="🎯 AI Foreman — 목표 분해",
description=(
f"**목표:** {goal}\n\n"
f"작업 트리를 생성 중... ⏳"
),
color=0x9B59B6,
)
await thread.send(embed=start_embed)
await interaction.followup.send(f"✅ 상담 스레드가 생성되었습니다: <#{thread.id}>", ephemeral=True)
# Gemini로 목표 분해
try:
tasks = await _foreman.decompose_goal(session)
if tasks:
tree_display = "\n".join(t.to_display() for t in tasks)
total = sum(len(t.to_flat_list()) for t in tasks)
result_embed = discord.Embed(
title="📋 작업 트리 (초안)",
description=tree_display[:4000],
color=0x2ECC71,
)
result_embed.set_footer(
text=f"{total}개 작업 | !확정 !수정 !추가 !삭제 !현황"
)
await thread.send(embed=result_embed)
else:
await thread.send("⚠️ 작업 분해에 실패했습니다. 목표를 더 구체적으로 입력해주세요.")
except GeminiCallError as e:
await thread.send(f"⚠️ AI 호출 오류: {str(e)[:300]}")
except Exception as e:
logger.error(f"Foreman 분해 오류: {e}", exc_info=True)
await thread.send(f"❌ 오류: {str(e)[:200]}")
# ──────────────────────────────────────────────
# 기존 ! 명령어 (유지, 하위호환)
# ──────────────────────────────────────────────
@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()