1263 lines
50 KiB
Python
1263 lines
50 KiB
Python
"""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), strict=False)
|
||
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], strict=False)
|
||
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=120)
|
||
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":
|
||
# 즉시 응답 — 항상 Embed 사용 (Discord markdown 렌더링)
|
||
response = parsed.get("response", "")
|
||
logger.info(f"[chat] 응답 길이: {len(response)}")
|
||
if response:
|
||
# 응답이 JSON 구조를 포함하면 제거 (fallback 시 raw 전체가 들어옴)
|
||
if response.strip().startswith('"mode"') or response.strip().startswith('{"mode"'):
|
||
import re as _re
|
||
m = _re.search(r'"response"\s*:\s*"(.*)', response, _re.DOTALL)
|
||
if m:
|
||
cleaned = m.group(1)
|
||
# 끝의 따옴표+중괄호 제거
|
||
if cleaned.endswith('"}'):
|
||
cleaned = cleaned[:-2]
|
||
elif cleaned.endswith('"'):
|
||
cleaned = cleaned[:-1]
|
||
response = cleaned.replace('\\n', '\n')
|
||
from handlers.renderer import safe_send_embed
|
||
for i in range(0, len(response), 4000):
|
||
chunk = response[i:i + 4000]
|
||
embed = discord.Embed(description=chunk, color=0x3498DB)
|
||
await safe_send_embed(message.channel, 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)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# Debate Room — 토론 채널 연동
|
||
# ──────────────────────────────────────────────
|
||
|
||
DEBATE_CHANNELS = {
|
||
"gemini": 1484156194187771905,
|
||
"opus": 1484156521209401476,
|
||
}
|
||
|
||
|
||
@bot.command(name="debate-test")
|
||
async def debate_test(ctx: commands.Context):
|
||
"""토론 채널 연결 테스트."""
|
||
results = []
|
||
for name, ch_id in DEBATE_CHANNELS.items():
|
||
ch = bot.get_channel(ch_id)
|
||
if ch is None:
|
||
try:
|
||
ch = await bot.fetch_channel(ch_id)
|
||
except Exception as e:
|
||
results.append(f"❌ {name}: 채널 접근 실패 — {e}")
|
||
continue
|
||
try:
|
||
await ch.send(f"🔗 Debate Room 연결 테스트 — `{name}` 채널 확인")
|
||
results.append(f"✅ {name}: 메시지 전송 성공")
|
||
except Exception as e:
|
||
results.append(f"❌ {name}: 전송 실패 — {e}")
|
||
|
||
embed = discord.Embed(
|
||
title="🏛️ Debate Channel Test",
|
||
description="\n".join(results),
|
||
color=0x2ECC71 if all("✅" in r for r in results) else 0xE74C3C,
|
||
)
|
||
await ctx.reply(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()
|