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