fix(anime): 파이프라인 5건 수정 — 에피소드 정규식(v2/S01E), 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃

This commit is contained in:
2026-03-15 08:27:08 +09:00
parent 63818999d9
commit 9f74812710
40 changed files with 2759 additions and 815 deletions

406
handlers/commands.py Normal file
View 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]}")