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

1
handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# handlers — Discord Bot 핸들러 패키지.

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

25
handlers/renderer.py Normal file
View 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
View 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,
)
)