"""슬래시 커맨드 — /workspace, /task 등 (discord_bot.py에서 분리). 워크스페이스 관리, 프로젝트 선택, 스레드 생성 등 Discord UI 인터랙션을 담당합니다. """ import logging from datetime import datetime from pathlib import Path import discord from discord import app_commands logger = logging.getLogger("variet.handlers.commands") def register_workspace_commands(bot, ws_manager): """워크스페이스 관련 슬래시 커맨드 등록.""" import config 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 = ""): 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 Path(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"): 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): 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) logger.info("워크스페이스 슬래시 커맨드 등록 완료") def register_task_command(bot, ws_manager, _project_threads, _thread_workspaces, _unified_call_fn): """프로젝트 선택 + 스레드 생성 슬래시 커맨드 등록.""" class ProjectSelectView(discord.ui.View): def __init__(self, request_text: str): super().__init__(timeout=60) self.request_text = request_text 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], ) 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 ws = ws_manager.get_workspace(int(selected_value)) if not ws: await interaction.response.send_message("❌ 워크스페이스를 찾을 수 없습니다.", ephemeral=True) return 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) 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 await interaction.response.defer() await _create_task_thread(interaction, ws, self.request_text, _project_threads, _thread_workspaces, _unified_call_fn) class ConflictView(discord.ui.View): def __init__(self, ws, request_text, original_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, button): await interaction.response.defer() await _create_task_thread(interaction, self.ws, self.request_text, _project_threads, _thread_workspaces, _unified_call_fn) self.stop() @discord.ui.button(label="🆕 새로 시작", style=discord.ButtonStyle.secondary) async def new_project(self, interaction, 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 archived_name = new_archived_path.name ws_manager.set_workspace( channel_id=-abs(hash(archived_name)) % (10**10), name=archived_name, path=str(new_archived_path), ) old_path.mkdir(parents=True, exist_ok=True) await interaction.response.defer() await _create_task_thread(interaction, self.ws, self.request_text, _project_threads, _thread_workspaces, _unified_call_fn) self.stop() @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, ) logger.info("/task 슬래시 커맨드 등록 완료") async def _create_task_thread( interaction, ws, request_text, _project_threads, _thread_workspaces, _unified_call_fn, ): """스레드를 생성하고 작업을 시작합니다.""" 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] channel = interaction.channel thread = await channel.create_thread( name=thread_name, type=discord.ChannelType.public_thread, auto_archive_duration=1440, ) _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) await interaction.followup.send( f"✅ 스레드가 생성되었습니다: <#{thread.id}>", ephemeral=True, ) if request_text.strip(): try: async with thread.typing(): result = await _unified_call_fn(request_text, "", ws.path) mode = result.get("mode", "chat") logger.info(f"[스레드] 통합 분류: {mode} - \"{request_text[:50]}\"") if mode == "chat": response = result.get("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) elif mode == "clarify": question = result.get("question", "더 구체적으로 말씀해 주시겠어요?") embed = discord.Embed( title="🤔 확인이 필요해요", description=question, color=0xF39C12, ) await thread.send(embed=embed) else: await thread.send( embed=discord.Embed( title="⚙️ 작업 모드 감지", description="이 스레드에서 작업 요청을 다시 입력해주세요.\n스레드 내 메시지는 자동으로 처리됩니다.", color=0xF39C12, ) ) except Exception as e: logger.error(f"스레드 초기 호출 오류: {e}", exc_info=True) await thread.send(f"⚠️ 초기 호출 오류: {str(e)[:200]}")