fix(anime): 파이프라인 5건 수정 — 에피소드 정규식(v2/S01E), 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃
This commit is contained in:
406
handlers/commands.py
Normal file
406
handlers/commands.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""슬래시 커맨드 — /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]}")
|
||||
Reference in New Issue
Block a user