fix(anime): 파이프라인 5건 수정 — 에피소드 정규식(v2/S01E), 릴리스 그룹 필터, 자막 보호, 배치 다운로드, 타임아웃
This commit is contained in:
1
handlers/__init__.py
Normal file
1
handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# handlers — Discord Bot 핸들러 패키지.
|
||||
229
handlers/anime_handler.py
Normal file
229
handlers/anime_handler.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""애니메이션 핸들러 — Discord Bot에서 분리된 애니 관련 처리.
|
||||
|
||||
AnimePipeline을 직접 호출하여 결과를 Discord Embed로 렌더링합니다.
|
||||
NLU에서 mode="anime"로 분류된 요청도 처리합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from handlers.renderer import safe_send_embed
|
||||
|
||||
logger = logging.getLogger("variet.handlers.anime")
|
||||
|
||||
|
||||
async def handle_anime_message(
|
||||
message: discord.Message,
|
||||
parsed: dict,
|
||||
):
|
||||
"""NLU에서 anime으로 분류된 메시지 처리.
|
||||
|
||||
Args:
|
||||
message: Discord 메시지
|
||||
parsed: NLU 분류 결과 dict {mode, action, title, episode?, ...}
|
||||
"""
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
|
||||
pipeline = AnimePipeline()
|
||||
action = parsed.get("action", "search")
|
||||
title = parsed.get("title", parsed.get("query", ""))
|
||||
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
if action in ("list", "scan"):
|
||||
# NAS 현황 조회
|
||||
from tools.nas_scanner import NasScanner
|
||||
scanner = NasScanner()
|
||||
if not scanner.is_accessible():
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="❌ NAS 접근 불가",
|
||||
description=f"경로: `{scanner.base_path}`",
|
||||
color=0xE74C3C,
|
||||
))
|
||||
return
|
||||
|
||||
# title이 있으면 키워드 검색, 없으면 이번 분기
|
||||
if title:
|
||||
folders = scanner.search(title)
|
||||
label = f"'{title}' 검색 결과"
|
||||
# 키워드 검색 0건이면 이번 분기로 fallback
|
||||
if not folders:
|
||||
folders = scanner.get_current_quarter_anime()
|
||||
label = "이번 분기 애니"
|
||||
else:
|
||||
folders = scanner.get_current_quarter_anime()
|
||||
label = "이번 분기 애니"
|
||||
|
||||
if not folders:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title=f"📁 {label}",
|
||||
description="해당하는 폴더가 없습니다.",
|
||||
color=0xF39C12,
|
||||
))
|
||||
return
|
||||
|
||||
desc_lines = []
|
||||
for f in folders[:15]:
|
||||
sub_info = f"자막 {f.subtitle_count}" if f.subtitle_count else "자막 없음"
|
||||
desc_lines.append(
|
||||
f"• `{f.folder_name}`\n 영상 {f.video_count}개 | {sub_info} | {f.total_size_gb:.1f}GB"
|
||||
)
|
||||
embed = discord.Embed(
|
||||
title=f"📁 {label} ({len(folders)}개)",
|
||||
description="\n".join(desc_lines)[:2000],
|
||||
color=0x3498DB,
|
||||
)
|
||||
await safe_send_embed(message.channel, embed)
|
||||
return
|
||||
|
||||
elif action == "search" and title:
|
||||
result = await pipeline.search(title)
|
||||
elif action == "search" and not title:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="🔍 애니 검색",
|
||||
description="검색할 제목을 입력해주세요.\n예: `프리렌 검색해줘`",
|
||||
color=0xF39C12,
|
||||
))
|
||||
return
|
||||
elif action == "download" and title:
|
||||
mode = parsed.get("download_mode", "auto")
|
||||
episode = parsed.get("episode")
|
||||
result = await pipeline.download(title, mode=mode, episode=episode)
|
||||
elif action == "status":
|
||||
status = await pipeline.get_status()
|
||||
if not status:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="🎬 다운로드 현황",
|
||||
description="다운로드 중인 항목 없음",
|
||||
color=0x3498DB,
|
||||
))
|
||||
return
|
||||
desc = "\n".join(
|
||||
f"• {s['progress']} `{s['name'][:40]}` {s['speed']}"
|
||||
for s in status
|
||||
)
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title=f"🎬 다운로드 현황 ({len(status)}건)",
|
||||
description=desc[:2000],
|
||||
color=0x3498DB,
|
||||
))
|
||||
return
|
||||
else:
|
||||
# 알 수 없는 action → search로 fallback
|
||||
if title:
|
||||
result = await pipeline.search(title)
|
||||
else:
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="❓ 애니 명령",
|
||||
description="무엇을 도와드릴까요?\n• 목록 조회: `NAS에 뭐있어?`\n• 검색: `프리렌 검색`\n• 다운로드: `프리렌 10화 받아줘`\n• 상태: `다운로드 현황`",
|
||||
color=0xF39C12,
|
||||
))
|
||||
return
|
||||
|
||||
# 결과 임베드
|
||||
embed = discord.Embed(
|
||||
title=f"🎬 {result.message[:100]}" if result.message else "🎬 결과",
|
||||
description=result.message[:2000] if result.message else "완료",
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
if result.errors:
|
||||
embed.add_field(
|
||||
name="⚠️ 오류",
|
||||
value="\n".join(f"• {e}" for e in result.errors[:5])[:1000],
|
||||
inline=False,
|
||||
)
|
||||
await safe_send_embed(message.channel, embed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"애니 핸들러 오류: {e}", exc_info=True)
|
||||
await message.channel.send(embed=discord.Embed(
|
||||
title="❌ 애니 처리 오류",
|
||||
description=f"```{str(e)[:300]}```",
|
||||
color=0xE74C3C,
|
||||
))
|
||||
|
||||
|
||||
def register_anime_commands(bot, ws_manager):
|
||||
"""애니메이션 슬래시 커맨드 등록."""
|
||||
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):
|
||||
await interaction.response.defer()
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
result = await pipeline.search(title)
|
||||
embed = discord.Embed(
|
||||
title=f"🔍 {result.anime.subject}" if result.anime else f"🔍 {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
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()
|
||||
result = await pipeline.download(title, mode="auto", episode=episode)
|
||||
embed = discord.Embed(
|
||||
title=f"📥 {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
await interaction.followup.send(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()
|
||||
result = await pipeline.download(title, mode="sub_only", episode=episode)
|
||||
embed = discord.Embed(
|
||||
title=f"📝 자막: {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success 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()
|
||||
result = await pipeline.download(title, mode="video_only", episode=episode)
|
||||
embed = discord.Embed(
|
||||
title=f"🎬 영상: {title}",
|
||||
description=result.message[:2000],
|
||||
color=0x2ECC71 if result.success else 0xE74C3C,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@anime_group.command(name="status", description="현재 다운로드 큐 상태")
|
||||
async def anime_status(interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
status = await pipeline.get_status()
|
||||
if not status:
|
||||
desc = "다운로드 중인 항목 없음"
|
||||
else:
|
||||
desc = "\n".join(
|
||||
f"• {s['progress']} `{s['name'][:40]}` {s['speed']}"
|
||||
for s in status
|
||||
)
|
||||
embed = discord.Embed(
|
||||
title=f"🎬 다운로드 현황 ({len(status)}건)",
|
||||
description=desc[:2000],
|
||||
color=0x3498DB,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
bot.tree.add_command(anime_group)
|
||||
logger.info("애니메이션 슬래시 커맨드 등록 완료")
|
||||
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]}")
|
||||
25
handlers/renderer.py
Normal file
25
handlers/renderer.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Discord 렌더러 — Embed 렌더링 유틸리티.
|
||||
|
||||
Discord Embed의 제한(4096자)을 고려한 안전한 전송 함수.
|
||||
"""
|
||||
|
||||
import discord
|
||||
|
||||
|
||||
EMBED_DESC_LIMIT = 4096
|
||||
|
||||
|
||||
async def safe_send_embed(channel, embed: discord.Embed):
|
||||
"""Embed가 Discord 제한을 초과하면 나눠서 전송."""
|
||||
desc = embed.description or ""
|
||||
if len(desc) <= EMBED_DESC_LIMIT:
|
||||
await channel.send(embed=embed)
|
||||
return
|
||||
|
||||
chunks = [desc[i:i + 4000] for i in range(0, len(desc), 4000)]
|
||||
embed.description = chunks[0]
|
||||
await channel.send(embed=embed)
|
||||
|
||||
for chunk in chunks[1:]:
|
||||
cont = discord.Embed(description=chunk, color=embed.color)
|
||||
await channel.send(embed=cont)
|
||||
180
handlers/task_handler.py
Normal file
180
handlers/task_handler.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""태스크 핸들러 — Agent 1회 실행 (discord_bot.py에서 분리).
|
||||
|
||||
NLU에서 mode="task"로 분류된 요청을 처리합니다.
|
||||
Agent가 plan+code+verify를 한 세션에서 수행합니다.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
|
||||
from core.gemini_caller import GeminiCallError
|
||||
from handlers.renderer import safe_send_embed
|
||||
|
||||
logger = logging.getLogger("variet.handlers.task")
|
||||
|
||||
|
||||
async def handle_task(
|
||||
message: discord.Message,
|
||||
text: str,
|
||||
ws,
|
||||
history: str = "",
|
||||
mode: str = "task",
|
||||
):
|
||||
"""작업 요청 — Agent 1회 통합 실행 + 결과 표시.
|
||||
|
||||
Args:
|
||||
message: Discord 메시지
|
||||
text: 사용자 입력 텍스트
|
||||
ws: Workspace 객체
|
||||
history: 대화 히스토리 문자열
|
||||
mode: 'task'(코딩) 또는 'anime'(도구 실행)
|
||||
"""
|
||||
from core.task_pipeline import TaskPipeline
|
||||
|
||||
task_id = uuid.uuid4().hex[:8]
|
||||
|
||||
# ── 1. 접수 ──
|
||||
embed = discord.Embed(
|
||||
title="⚙️ 작업 중...",
|
||||
description=f"```{text[:200]}```",
|
||||
color=0xF39C12,
|
||||
)
|
||||
embed.set_footer(text=f"ID: {task_id} | {ws.name}")
|
||||
status_msg = await message.channel.send(embed=embed)
|
||||
|
||||
try:
|
||||
pipeline = TaskPipeline(
|
||||
project_path=ws.path,
|
||||
docs_subpath=ws.docs_path,
|
||||
)
|
||||
pipeline.setup()
|
||||
|
||||
# ── 2. Agent 실행 (진행 상태 실시간 업데이트) ──
|
||||
import time as _time
|
||||
_start_time = _time.time()
|
||||
_last_update = [0.0]
|
||||
_current_status = ["작업 중..."]
|
||||
|
||||
async def _progress(status_text: str):
|
||||
now = _time.time()
|
||||
if now - _last_update[0] < 2.0:
|
||||
return
|
||||
_last_update[0] = now
|
||||
_current_status[0] = status_text
|
||||
elapsed = int(now - _start_time)
|
||||
try:
|
||||
embed.title = f"⚙️ {status_text}"
|
||||
embed.set_footer(text=f"ID: {task_id} | {ws.name} | {elapsed}초 경과")
|
||||
await status_msg.edit(embed=embed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# heartbeat: 출력이 없어도 10초마다 경과시간 갱신
|
||||
_heartbeat_running = [True]
|
||||
|
||||
async def _heartbeat():
|
||||
while _heartbeat_running[0]:
|
||||
await asyncio.sleep(10)
|
||||
if not _heartbeat_running[0]:
|
||||
break
|
||||
elapsed = int(_time.time() - _start_time)
|
||||
try:
|
||||
embed.set_footer(text=f"ID: {task_id} | {ws.name} | {elapsed}초 경과")
|
||||
await status_msg.edit(embed=embed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
heartbeat_task = asyncio.create_task(_heartbeat())
|
||||
|
||||
# mode→role 매핑: anime='operator'(도구 실행), task='agent'(코딩)
|
||||
role = "operator" if mode == "anime" else "agent"
|
||||
timeout = 600 # anime 배치 작업(20+건 순차)도 충분한 시간 확보
|
||||
|
||||
try:
|
||||
result = await pipeline.execute(
|
||||
text, history=history, progress_callback=_progress,
|
||||
role=role,
|
||||
)
|
||||
finally:
|
||||
_heartbeat_running[0] = False
|
||||
try:
|
||||
heartbeat_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 3. 결과 표시 ──
|
||||
title = result.get("title", "작업 완료")
|
||||
summary = result.get("summary", "완료")
|
||||
verified = result.get("verified", False)
|
||||
|
||||
color = 0x2ECC71 if verified else 0xF39C12
|
||||
result_embed = discord.Embed(
|
||||
title=f"{'✅' if verified else '📋'} {title}",
|
||||
description=summary[:2000],
|
||||
color=color,
|
||||
)
|
||||
|
||||
# 변경 사항
|
||||
changes = result.get("changes", [])
|
||||
if changes:
|
||||
if isinstance(changes[0], dict):
|
||||
val = "\n".join(
|
||||
f"• **{c.get('title', c.get('file', c.get('name', '?')))}** — "
|
||||
f"{c.get('action', c.get('description', c.get('summary', '')))}"
|
||||
for c in changes[:10]
|
||||
)
|
||||
else:
|
||||
val = "\n".join(f"• {s}" for s in changes[:10])
|
||||
result_embed.add_field(name="변경 사항", value=val[:1000], inline=False)
|
||||
|
||||
# 주의사항
|
||||
warnings = result.get("warnings", [])
|
||||
if warnings:
|
||||
result_embed.add_field(
|
||||
name="⚠️ 주의",
|
||||
value="\n".join(f"• {w}" for w in warnings[:5])[:1000],
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# 다음 단계
|
||||
next_steps = result.get("next_steps", [])
|
||||
if next_steps:
|
||||
result_embed.add_field(
|
||||
name="🔜 다음 단계",
|
||||
value="\n".join(f"• {s}" for s in next_steps[:5])[:1000],
|
||||
inline=False,
|
||||
)
|
||||
|
||||
result_embed.set_footer(text=f"ID: {task_id} | {ws.name}")
|
||||
await status_msg.edit(embed=result_embed)
|
||||
|
||||
# 기록 (실패해도 봇 종료 방지)
|
||||
try:
|
||||
pipeline.docs.record_session(text, result, {})
|
||||
pipeline.docs.append_changelog(title)
|
||||
except Exception as doc_err:
|
||||
logger.warning(f"세션 기록 실패: {doc_err}")
|
||||
|
||||
except GeminiCallError as e:
|
||||
await status_msg.edit(
|
||||
embed=discord.Embed(
|
||||
title="❌ AI 호출 오류",
|
||||
description=(
|
||||
f"```{str(e)[:300]}```\n\n"
|
||||
f"💡 요청을 더 짧게/구체적으로 다시 시도해보세요."
|
||||
),
|
||||
color=0xE74C3C,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"작업 오류: {e}", exc_info=True)
|
||||
await status_msg.edit(
|
||||
embed=discord.Embed(
|
||||
title="❌ 예기치 않은 오류",
|
||||
description=f"```{str(e)[:300]}```",
|
||||
color=0xE74C3C,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user