feat(tools): 애니메이션 자동화 파이프라인 구현
- tools/anissia_client.py: Anissia API 클라이언트 (편성표/자막) - tools/nyaa_client.py: Nyaa.si RSS 토렌트 검색 - tools/qbit_client.py: qBittorrent Web API 클라이언트 - tools/subtitle_downloader.py: Google Drive/Tistory/Naver 자막 파서 - tools/title_matcher.py: 제목 매칭 + NAS 폴더명 생성 - tools/anime_pipeline.py: 전체 파이프라인 오케스트레이터 - tools/nas_scanner.py: NAS 폴더/파일 스캔 - prompts/unified.md: anime 모드 추가 (AI 평문 의도 분류) - api/discord_bot.py: AI 평문 anime 핸들러 + /anime 슬래시 커맨드 - config.py: qBittorrent/NAS 설정 추가 - .agents/: agent_guide 워크플로우 통합 - docs/devlog: 세션 기록
This commit is contained in:
@@ -2,12 +2,15 @@
|
||||
|
||||
슬래시 커맨드로 워크스페이스 관리.
|
||||
등록된 채널에서 자동 대화 + 통합 프롬프트 (1회 호출로 분류+응답).
|
||||
/task 커맨드로 프로젝트 선택 → 스레드 자동 생성 → 스레드 내 작업.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
@@ -58,6 +61,10 @@ ws_manager = WorkspaceManager()
|
||||
# 실행 중인 작업 추적 (채널ID → asyncio.Task)
|
||||
_running_tasks: dict[int, asyncio.Task] = {}
|
||||
|
||||
# 스레드 ↔ 프로젝트 매핑
|
||||
_project_threads: dict[str, int] = {} # 프로젝트명 → 활성 스레드 ID
|
||||
_thread_workspaces: dict[int, "Workspace"] = {} # 스레드 ID → Workspace
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 대화 기억
|
||||
@@ -99,6 +106,23 @@ async def _get_channel_history(channel: discord.TextChannel, limit: int = 10) ->
|
||||
return "=== CONVERSATION HISTORY ===\n" + "\n".join(messages) + "\n\n"
|
||||
|
||||
|
||||
async def safe_send_embed(channel, embed: discord.Embed):
|
||||
"""Embed 전송 (description 길이 초과 시 자동 분할)."""
|
||||
desc = embed.description or ""
|
||||
if len(desc) <= 4096:
|
||||
await channel.send(embed=embed)
|
||||
else:
|
||||
# 분할 전송
|
||||
for i in range(0, len(desc), 4000):
|
||||
chunk_embed = discord.Embed(
|
||||
title=embed.title if i == 0 else None,
|
||||
description=desc[i:i+4000],
|
||||
color=embed.color,
|
||||
)
|
||||
await channel.send(embed=chunk_embed)
|
||||
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 통합 프롬프트 (1회 호출: 분류 + 응답/계획)
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -160,10 +184,16 @@ async def on_ready():
|
||||
|
||||
# 슬래시 커맨드 동기화 (길드별 = 즉시 반영)
|
||||
try:
|
||||
# 1) 글로벌 커맨드를 각 길드로 복사 + 동기화
|
||||
for guild in bot.guilds:
|
||||
bot.tree.copy_global_to(guild=guild)
|
||||
synced = await bot.tree.sync(guild=guild)
|
||||
logger.info(f"슬래시 커맨드 {len(synced)}개 동기화 완료 (서버: {guild.name})")
|
||||
|
||||
# 2) 글로벌 커맨드 제거 (길드 커맨드와 중복 방지)
|
||||
bot.tree.clear_commands(guild=None)
|
||||
await bot.tree.sync()
|
||||
logger.info("글로벌 슬래시 커맨드 정리 완료 (길드 전용)")
|
||||
except Exception as e:
|
||||
logger.error(f"슬래시 커맨드 동기화 실패: {e}")
|
||||
|
||||
@@ -236,11 +266,15 @@ async def on_message(message: discord.Message):
|
||||
await bot.process_commands(message)
|
||||
return
|
||||
|
||||
# 워크스페이스 채널인지 확인
|
||||
if not ws_manager.is_workspace_channel(message.channel.id):
|
||||
return
|
||||
|
||||
# 워크스페이스 채널 또는 스레드 확인
|
||||
ws = ws_manager.get_workspace(message.channel.id)
|
||||
if not ws and message.channel.id in _thread_workspaces:
|
||||
ws = _thread_workspaces[message.channel.id]
|
||||
# 스레드의 부모 채널이 워크스페이스인 경우
|
||||
if not ws and isinstance(message.channel, discord.Thread):
|
||||
ws = ws_manager.get_workspace(message.channel.parent_id)
|
||||
if not ws:
|
||||
return
|
||||
user_text = message.content.strip()
|
||||
if not user_text:
|
||||
return
|
||||
@@ -279,7 +313,11 @@ async def on_message(message: discord.Message):
|
||||
mode = result.get("mode", "chat")
|
||||
logger.info(f"통합 분류: {mode} - \"{user_text[:50]}\"")
|
||||
|
||||
if mode == "task":
|
||||
if mode == "anime":
|
||||
# 애니메이션 도구 자동 호출
|
||||
async with message.channel.typing():
|
||||
await _handle_anime(message, result)
|
||||
elif mode == "task":
|
||||
# Git/Vikunja 미설정 안내 (차단하지 않음)
|
||||
if not ws.is_ready:
|
||||
missing = ws.missing_configs
|
||||
@@ -330,6 +368,284 @@ async def on_message(message: discord.Message):
|
||||
await message.channel.send(embed=embed)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Anime 핸들러 (AI가 분류한 의도 실행)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def _handle_anime(message: discord.Message, parsed: dict):
|
||||
"""AI가 분류한 anime 의도를 실행."""
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
|
||||
action = parsed.get("action", "search")
|
||||
title = parsed.get("title", "")
|
||||
episode = parsed.get("episode")
|
||||
filter_str = parsed.get("filter", "")
|
||||
summary = parsed.get("summary", "")
|
||||
|
||||
pipeline = AnimePipeline()
|
||||
|
||||
try:
|
||||
if action == "status":
|
||||
await _anime_status(message, pipeline)
|
||||
|
||||
elif action == "schedule":
|
||||
await _anime_schedule(message, pipeline, filter_str)
|
||||
|
||||
elif action == "list":
|
||||
await _anime_list(message, pipeline, filter_str)
|
||||
|
||||
elif action in ("download", "sub_only", "video_only"):
|
||||
# 필터에 batch 조건이 있으면 복수 다운로드
|
||||
if not title and filter_str:
|
||||
await _anime_batch(message, pipeline, action, filter_str)
|
||||
else:
|
||||
await _anime_download(message, pipeline, title, action, episode)
|
||||
|
||||
else: # search (기본)
|
||||
if not title:
|
||||
await message.reply("🔍 어떤 애니를 검색할까요? 제목을 알려주세요.")
|
||||
return
|
||||
await _anime_search(message, pipeline, title)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Anime 핸들러 오류: {e}", exc_info=True)
|
||||
await message.reply(f"❌ 오류가 발생했습니다: {str(e)[:300]}")
|
||||
|
||||
|
||||
async def _anime_search(message, pipeline, title):
|
||||
"""검색 결과 표시."""
|
||||
result = await pipeline.search(title)
|
||||
if not result.anime:
|
||||
await message.reply(f"❌ '{title}' 검색 결과가 없습니다.")
|
||||
return
|
||||
|
||||
anime = result.anime
|
||||
embed = discord.Embed(
|
||||
title=f"🔍 {anime.subject}",
|
||||
description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}",
|
||||
color=0x3498DB,
|
||||
)
|
||||
week_names = ['일','월','화','수','목','금','토','기타']
|
||||
embed.add_field(name="📅 편성", value=f"{week_names[anime.week]}요일 {anime.time}", inline=True)
|
||||
embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True)
|
||||
|
||||
if result.captions:
|
||||
cap_lines = []
|
||||
for c in result.captions[:5]:
|
||||
url_text = f"[사이트]({c.website})" if c.website else "URL 없음"
|
||||
cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})")
|
||||
embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False)
|
||||
|
||||
if result.torrents:
|
||||
tor_lines = []
|
||||
for t in result.torrents[:5]:
|
||||
ep = f"**{t.episode}화**" if t.episode else ""
|
||||
tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})")
|
||||
embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False)
|
||||
|
||||
await safe_send_embed(message.channel, embed)
|
||||
|
||||
|
||||
async def _anime_download(message, pipeline, title, mode, episode):
|
||||
"""단일 애니 다운로드."""
|
||||
if not title:
|
||||
await message.reply("📥 어떤 애니를 다운받을까요? 제목을 알려주세요.")
|
||||
return
|
||||
|
||||
embed = discord.Embed(title="⏳ 처리 중...", description=f"**{title}** 검색 및 다운로드", color=0xF39C12)
|
||||
status_msg = await message.channel.send(embed=embed)
|
||||
|
||||
result = await pipeline.download(title, mode=mode, episode=episode)
|
||||
|
||||
if result.torrent_added or result.subtitles:
|
||||
embed.title = "✅ 완료"
|
||||
embed.color = 0x2ECC71
|
||||
else:
|
||||
embed.title = "⚠️ 부분 완료"
|
||||
embed.color = 0xF39C12
|
||||
|
||||
embed.description = result.message[:4000]
|
||||
await status_msg.edit(embed=embed)
|
||||
|
||||
|
||||
async def _anime_batch(message, pipeline, action, filter_str):
|
||||
"""필터 기반 복수 애니 다운로드 (이번분기 자막있는것 등)."""
|
||||
embed = discord.Embed(title="⏳ 편성표 분석 중...", description="조건에 맞는 애니 검색", color=0xF39C12)
|
||||
status_msg = await message.channel.send(embed=embed)
|
||||
|
||||
# 전체 편성표 로드
|
||||
all_anime = await pipeline.anissia.get_all_schedule()
|
||||
|
||||
# 필터 적용
|
||||
filtered = all_anime
|
||||
if "sub:yes" in filter_str or "자막" in filter_str:
|
||||
filtered = [a for a in filtered if a.caption_count > 0]
|
||||
if "quarter:current" in filter_str or "이번" in filter_str:
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
current_q = (today.month - 1) // 3 + 1
|
||||
current_year = today.year
|
||||
def _in_current_quarter(a):
|
||||
if not a.start_date:
|
||||
return False
|
||||
parts = a.start_date.split("-")
|
||||
y, m = int(parts[0]), int(parts[1])
|
||||
q = (m - 1) // 3 + 1
|
||||
return y == current_year and q == current_q
|
||||
filtered = [a for a in filtered if _in_current_quarter(a)]
|
||||
if "status:on" in filter_str:
|
||||
filtered = [a for a in filtered if a.status == "ON"]
|
||||
else:
|
||||
# 기본: ON 상태만
|
||||
filtered = [a for a in filtered if a.status == "ON"]
|
||||
|
||||
embed.title = f"📋 조건 매칭: {len(filtered)}개"
|
||||
embed.description = "\n".join(f"• {a.subject} (자막 {a.caption_count}명)" for a in filtered[:15])
|
||||
if len(filtered) > 15:
|
||||
embed.description += f"\n... 외 {len(filtered)-15}개"
|
||||
embed.color = 0x3498DB
|
||||
await status_msg.edit(embed=embed)
|
||||
|
||||
if not filtered:
|
||||
return
|
||||
|
||||
# 다운로드 실행
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
for anime in filtered:
|
||||
try:
|
||||
result = await pipeline.download(anime.subject, mode=action)
|
||||
if result.torrent_added or result.subtitles:
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"배치 다운로드 오류 ({anime.subject}): {e}")
|
||||
fail_count += 1
|
||||
|
||||
result_embed = discord.Embed(
|
||||
title=f"📊 배치 다운로드 결과",
|
||||
description=f"✅ 성공: {success_count}개\n⚠️ 실패/보류: {fail_count}개",
|
||||
color=0x2ECC71 if success_count > 0 else 0xF39C12,
|
||||
)
|
||||
await message.channel.send(embed=result_embed)
|
||||
|
||||
|
||||
async def _anime_schedule(message, pipeline, filter_str):
|
||||
"""편성표 조회."""
|
||||
# 요일 파싱
|
||||
week = None
|
||||
week_map = {"일": 0, "월": 1, "화": 2, "수": 3, "목": 4, "금": 5, "토": 6}
|
||||
for name, num in week_map.items():
|
||||
if name in filter_str:
|
||||
week = num
|
||||
break
|
||||
if "week:" in filter_str:
|
||||
m = re.search(r'week:(\d)', filter_str)
|
||||
if m:
|
||||
week = int(m.group(1))
|
||||
|
||||
if week is not None:
|
||||
schedule = await pipeline.anissia.get_schedule(week)
|
||||
week_names = ['일','월','화','수','목','금','토','기타']
|
||||
title = f"📅 {week_names[week]}요일 편성표"
|
||||
else:
|
||||
schedule = await pipeline.anissia.get_all_schedule()
|
||||
schedule = [a for a in schedule if a.status == "ON"]
|
||||
title = f"📅 이번 분기 방영 중인 애니"
|
||||
|
||||
# 자막 있는것 필터
|
||||
if "sub:yes" in filter_str or "자막" in filter_str:
|
||||
schedule = [a for a in schedule if a.caption_count > 0]
|
||||
title += " (자막 있음)"
|
||||
|
||||
lines = []
|
||||
for a in schedule[:25]:
|
||||
sub_icon = "📝" if a.caption_count > 0 else " "
|
||||
lines.append(f"{sub_icon} **{a.subject}** — {a.time} (자막 {a.caption_count}명)")
|
||||
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
description="\n".join(lines) if lines else "결과 없음",
|
||||
color=0x3498DB,
|
||||
)
|
||||
if len(schedule) > 25:
|
||||
embed.set_footer(text=f"총 {len(schedule)}개 중 25개 표시")
|
||||
await safe_send_embed(message.channel, embed)
|
||||
|
||||
|
||||
async def _anime_list(message, pipeline, filter_str):
|
||||
"""NAS에 다운로드된 애니 목록."""
|
||||
if not pipeline.nas.is_accessible():
|
||||
await message.reply(f"❌ NAS 경로 접근 불가: `{pipeline.nas.base_path}`")
|
||||
return
|
||||
|
||||
# 분기 필터링
|
||||
year, quarter = None, None
|
||||
if "quarter:current" in filter_str or "이번" in filter_str:
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
year = today.year % 100
|
||||
quarter = (today.month - 1) // 3 + 1
|
||||
|
||||
folders = pipeline.nas.list_anime_folders(year=year, quarter=quarter)
|
||||
|
||||
if not folders:
|
||||
q_text = f" ({year}년 {quarter}분기)" if year else ""
|
||||
await message.reply(f"📂 다운로드된 애니가 없습니다{q_text}.")
|
||||
return
|
||||
|
||||
total_vids = sum(f.video_count for f in folders)
|
||||
total_subs = sum(f.subtitle_count for f in folders)
|
||||
total_size = sum(f.total_size_gb for f in folders)
|
||||
|
||||
lines = []
|
||||
for f in folders:
|
||||
sub_icon = "📝" if f.subtitle_count > 0 else ""
|
||||
lines.append(
|
||||
f"• **{f.title}** — 🎬{f.video_count}화 {sub_icon}{f.subtitle_count}자막 "
|
||||
f"({f.total_size_gb:.1f}GB)"
|
||||
)
|
||||
|
||||
q_text = f"{year}년 {quarter}분기" if year else "전체"
|
||||
embed = discord.Embed(
|
||||
title=f"📂 다운로드된 애니 ({q_text}: {len(folders)}개)",
|
||||
description="\n".join(lines[:25]),
|
||||
color=0x2ECC71,
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"총 {total_vids}개 영상 | {total_subs}개 자막 | {total_size:.1f}GB"
|
||||
)
|
||||
if len(folders) > 25:
|
||||
embed.description += f"\n... 외 {len(folders)-25}개"
|
||||
await safe_send_embed(message.channel, embed)
|
||||
|
||||
|
||||
async def _anime_status(message, pipeline):
|
||||
"""qBittorrent 상태 표시."""
|
||||
conn = await pipeline.qbit.test_connection()
|
||||
if not conn.get("connected"):
|
||||
await message.reply(f"❌ qBittorrent 연결 실패: {conn.get('error', '?')}")
|
||||
return
|
||||
|
||||
torrents = await pipeline.get_status()
|
||||
embed = discord.Embed(
|
||||
title=f"📊 다운로드 큐 ({len(torrents)}건)",
|
||||
description=f"qBittorrent {conn.get('version', '?')}",
|
||||
color=0x3498DB,
|
||||
)
|
||||
for t in torrents[:10]:
|
||||
icon = "✅" if t["progress"] == "100.0%" else "⏳"
|
||||
embed.add_field(
|
||||
name=f"{icon} {t['name'][:50]}",
|
||||
value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}",
|
||||
inline=False,
|
||||
)
|
||||
if not torrents:
|
||||
embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False)
|
||||
await safe_send_embed(message.channel, embed)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 설정 경고
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -445,13 +761,25 @@ async def _handle_task(message: discord.Message, text: str, ws):
|
||||
)
|
||||
await message.channel.send(embed=plan_embed)
|
||||
else:
|
||||
await message.channel.send(
|
||||
embed=discord.Embed(
|
||||
title="⚠️ 실행할 태스크 없음",
|
||||
description="요청을 더 구체적으로 해주세요.",
|
||||
color=0xF39C12,
|
||||
# 태스크가 없지만 summary가 있으면 결과로 표시 (분류 경계 케이스)
|
||||
summary_text = plan.get("summary", "") or plan.get("result", "")
|
||||
if summary_text:
|
||||
await message.channel.send(
|
||||
embed=discord.Embed(
|
||||
title="📋 분석 결과",
|
||||
description=summary_text[:4000],
|
||||
color=0x3498DB,
|
||||
)
|
||||
)
|
||||
pipeline.docs.record_session(text, {"summary": summary_text}, plan)
|
||||
else:
|
||||
await message.channel.send(
|
||||
embed=discord.Embed(
|
||||
title="⚠️ 실행할 태스크 없음",
|
||||
description="요청을 더 구체적으로 해주세요.",
|
||||
color=0xF39C12,
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# 2. Planner 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증)
|
||||
@@ -593,14 +921,29 @@ async def _handle_task(message: discord.Message, text: str, ws):
|
||||
|
||||
except GeminiCallError as e:
|
||||
await message.channel.send(
|
||||
embed=discord.Embed(title="❌ AI 호출 오류",
|
||||
description=f"```{str(e)[:500]}```", color=0xE74C3C)
|
||||
embed=discord.Embed(
|
||||
title="❌ AI 호출 오류",
|
||||
description=(
|
||||
f"```{str(e)[:300]}```\n\n"
|
||||
f"💡 **대응 방법:**\n"
|
||||
f"• 요청을 더 짧게/구체적으로 다시 시도\n"
|
||||
f"• 복잡한 요청은 단계별로 나눠서 요청\n"
|
||||
f"• 잠시 후 다시 시도"
|
||||
),
|
||||
color=0xE74C3C,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"작업 오류: {e}", exc_info=True)
|
||||
await message.channel.send(
|
||||
embed=discord.Embed(title="❌ 오류",
|
||||
description=f"```{str(e)[:500]}```", color=0xE74C3C)
|
||||
embed=discord.Embed(
|
||||
title="❌ 예기치 않은 오류",
|
||||
description=(
|
||||
f"```{str(e)[:300]}```\n\n"
|
||||
f"💡 다시 요청하시거나, 문제가 계속되면 관리자에게 문의하세요."
|
||||
),
|
||||
color=0xE74C3C,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -786,10 +1129,475 @@ async def workspace_list(interaction: discord.Interaction):
|
||||
bot.tree.add_command(workspace_group)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# /task 커맨드 — 프로젝트 선택 + 스레드 생성
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class ProjectSelectView(discord.ui.View):
|
||||
"""프로젝트 드롭다운 + 스레드 생성."""
|
||||
|
||||
def __init__(self, request_text: str):
|
||||
super().__init__(timeout=60)
|
||||
self.request_text = request_text
|
||||
|
||||
# 워크스페이스 목록으로 Select 옵션 구성 (channel_id를 value로 사용 — 고유 식별)
|
||||
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], # Discord 제한
|
||||
)
|
||||
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
|
||||
|
||||
# channel_id로 워크스페이스 직접 조회 (이름 충돌 방지)
|
||||
ws = ws_manager.get_workspace(int(selected_value))
|
||||
if not ws:
|
||||
await interaction.response.send_message("❌ 워크스페이스를 찾을 수 없습니다.", ephemeral=True)
|
||||
return
|
||||
|
||||
# 1) 활성 스레드가 이미 있는지 확인
|
||||
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)
|
||||
|
||||
# 2) 기존 프로젝트 폴더가 있는지 확인 (충돌 체크)
|
||||
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
|
||||
|
||||
# 3) 폴더 없거나 비어있음 → 바로 스레드 생성
|
||||
await interaction.response.defer()
|
||||
await _create_task_thread(interaction, ws, self.request_text)
|
||||
|
||||
|
||||
class ConflictView(discord.ui.View):
|
||||
"""기존 프로젝트 이어가기 / 새로 시작 선택."""
|
||||
|
||||
def __init__(self, ws, request_text: str, original_interaction: discord.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: discord.Interaction, button: discord.ui.Button):
|
||||
"""기존 프로젝트 폴더로 새 스레드 생성."""
|
||||
await interaction.response.defer()
|
||||
await _create_task_thread(interaction, self.ws, self.request_text)
|
||||
self.stop()
|
||||
|
||||
@discord.ui.button(label="🆕 새로 시작", style=discord.ButtonStyle.secondary)
|
||||
async def new_project(self, interaction: discord.Interaction, button: discord.ui.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
|
||||
|
||||
# 아카이브된 프로젝트를 workspaces에 등록 (접근 유지)
|
||||
archived_name = new_archived_path.name
|
||||
archived_ws = ws_manager.set_workspace(
|
||||
channel_id=-abs(hash(archived_name)) % (10**10),
|
||||
name=archived_name,
|
||||
path=str(new_archived_path),
|
||||
)
|
||||
logger.info(f"아카이브 워크스페이스 등록: {archived_name}")
|
||||
|
||||
# 새 폴더 생성
|
||||
old_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
await interaction.response.defer()
|
||||
await _create_task_thread(interaction, self.ws, self.request_text)
|
||||
self.stop()
|
||||
|
||||
|
||||
async def _create_task_thread(
|
||||
interaction: discord.Interaction,
|
||||
ws,
|
||||
request_text: str,
|
||||
):
|
||||
"""스레드를 생성하고 작업을 시작합니다."""
|
||||
# 스레드 제목: 프로젝트명 + 요청 앞부분
|
||||
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] # Discord 제한
|
||||
|
||||
# 스레드 생성
|
||||
channel = interaction.channel
|
||||
thread = await channel.create_thread(
|
||||
name=thread_name,
|
||||
type=discord.ChannelType.public_thread,
|
||||
auto_archive_duration=1440, # 24시간 후 자동 아카이브
|
||||
)
|
||||
|
||||
# 매핑 등록
|
||||
_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)
|
||||
|
||||
# followup으로 스레드 안내
|
||||
await interaction.followup.send(
|
||||
f"✅ 스레드가 생성되었습니다: <#{thread.id}>",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# 작업 실행 (가짜 Message 대신 스레드에 직접 메시지 전송)
|
||||
if request_text.strip():
|
||||
# 통합 프롬프트 호출
|
||||
try:
|
||||
async with thread.typing():
|
||||
history = ""
|
||||
result = await _unified_call(request_text, history, 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: # task
|
||||
# task 모드 — 스레드에서 파이프라인 안내
|
||||
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]}")
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 스레드 이벤트 — 아카이브/삭제 시 매핑 정리
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@bot.event
|
||||
async def on_thread_update(before, after):
|
||||
"""스레드 아카이브 감지 → 매핑 정리."""
|
||||
if after.archived and after.id in _thread_workspaces:
|
||||
ws = _thread_workspaces.pop(after.id)
|
||||
_project_threads.pop(ws.name, None)
|
||||
logger.info(f"스레드 아카이브 감지 → 매핑 제거: {ws.name} (스레드 {after.id})")
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_thread_delete(thread):
|
||||
"""스레드 삭제 감지 → 매핑 정리."""
|
||||
if thread.id in _thread_workspaces:
|
||||
ws = _thread_workspaces.pop(thread.id)
|
||||
_project_threads.pop(ws.name, None)
|
||||
logger.info(f"스레드 삭제 감지 → 매핑 제거: {ws.name} (스레드 {thread.id})")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# /anime 커맨드 — 애니메이션 자동화
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
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):
|
||||
"""Anissia + Nyaa 통합 검색."""
|
||||
await interaction.response.defer()
|
||||
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
|
||||
try:
|
||||
result = await pipeline.search(title)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"❌ 검색 오류: {e}")
|
||||
return
|
||||
|
||||
if not result.anime:
|
||||
await interaction.followup.send(f"❌ '{title}' 검색 결과가 없습니다.")
|
||||
return
|
||||
|
||||
anime = result.anime
|
||||
embed = discord.Embed(
|
||||
title=f"🔍 {anime.subject}",
|
||||
description=f"**원제**: {anime.original_subject}\n**장르**: {anime.genres}",
|
||||
color=0x3498DB,
|
||||
)
|
||||
embed.add_field(
|
||||
name="📅 편성",
|
||||
value=f"{['일','월','화','수','목','금','토','기타'][anime.week]}요일 {anime.time}",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="📁 NAS 폴더", value=f"`{result.nas_folder}`", inline=True)
|
||||
|
||||
# 자막 정보
|
||||
if result.captions:
|
||||
cap_lines = []
|
||||
for c in result.captions[:5]:
|
||||
url_text = f"[사이트]({c.website})" if c.website else "URL 없음"
|
||||
cap_lines.append(f"• **{c.name}** — {c.episode}화 ({url_text})")
|
||||
embed.add_field(name=f"📝 자막 ({len(result.captions)}명)", value="\n".join(cap_lines), inline=False)
|
||||
else:
|
||||
embed.add_field(name="📝 자막", value="등록된 자막 없음", inline=False)
|
||||
|
||||
# 토렌트 정보
|
||||
if result.torrents:
|
||||
tor_lines = []
|
||||
for t in result.torrents[:5]:
|
||||
ep = f"**{t.episode}화**" if t.episode else ""
|
||||
tor_lines.append(f"• [{t.group}] {ep} {t.size} (🌱{t.seeders})")
|
||||
embed.add_field(name=f"🎬 토렌트 ({len(result.torrents)}건)", value="\n".join(tor_lines), inline=False)
|
||||
else:
|
||||
embed.add_field(name="🎬 토렌트", value="검색 결과 없음", inline=False)
|
||||
|
||||
if result.errors:
|
||||
embed.set_footer(text="⚠️ " + "; ".join(result.errors))
|
||||
|
||||
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()
|
||||
|
||||
embed = discord.Embed(title="⏳ 다운로드 진행 중...", description=f"**{title}**", color=0xF39C12)
|
||||
msg = await interaction.followup.send(embed=embed, wait=True)
|
||||
|
||||
try:
|
||||
result = await pipeline.download(title, mode="auto", episode=episode)
|
||||
except Exception as e:
|
||||
embed.title = "❌ 다운로드 오류"
|
||||
embed.description = str(e)[:500]
|
||||
embed.color = 0xE74C3C
|
||||
await msg.edit(embed=embed)
|
||||
return
|
||||
|
||||
embed.title = "✅ 다운로드 완료" if result.torrent_added or result.subtitles else "⚠️ 부분 완료"
|
||||
embed.description = result.message[:4000]
|
||||
embed.color = 0x2ECC71 if result.torrent_added or result.subtitles else 0xF39C12
|
||||
await msg.edit(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()
|
||||
|
||||
try:
|
||||
result = await pipeline.download(title, mode="sub_only", episode=episode)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"❌ 오류: {e}")
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📝 자막 다운로드 {'완료' if result.subtitles else '실패'}",
|
||||
description=result.message[:4000],
|
||||
color=0x2ECC71 if result.subtitles 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()
|
||||
|
||||
try:
|
||||
result = await pipeline.download(title, mode="video_only", episode=episode)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"❌ 오류: {e}")
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"🎬 영상 다운로드 {'추가됨' if result.torrent_added else '실패'}",
|
||||
description=result.message[:4000],
|
||||
color=0x2ECC71 if result.torrent_added else 0xE74C3C,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
|
||||
@anime_group.command(name="status", description="현재 다운로드 큐 상태")
|
||||
async def anime_status(interaction: discord.Interaction):
|
||||
"""qBittorrent 다운로드 상태 확인."""
|
||||
await interaction.response.defer()
|
||||
|
||||
from tools.anime_pipeline import AnimePipeline
|
||||
pipeline = AnimePipeline()
|
||||
|
||||
# 연결 테스트
|
||||
conn = await pipeline.qbit.test_connection()
|
||||
if not conn.get("connected"):
|
||||
await interaction.followup.send(
|
||||
embed=discord.Embed(
|
||||
title="❌ qBittorrent 연결 실패",
|
||||
description=f"URL: `{conn.get('url')}`\n오류: {conn.get('error', '?')}",
|
||||
color=0xE74C3C,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
torrents = await pipeline.get_status()
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📊 다운로드 큐 ({len(torrents)}건)",
|
||||
description=f"qBittorrent {conn.get('version', '?')} | API {conn.get('api_version', '?')}",
|
||||
color=0x3498DB,
|
||||
)
|
||||
|
||||
if torrents:
|
||||
for t in torrents[:10]:
|
||||
status_icon = "✅" if t["progress"] == "100.0%" else "⏳"
|
||||
embed.add_field(
|
||||
name=f"{status_icon} {t['name'][:50]}",
|
||||
value=f"진행: {t['progress']} | 속도: {t['speed']} | ETA: {t['eta']}",
|
||||
inline=False,
|
||||
)
|
||||
else:
|
||||
embed.add_field(name="상태", value="다운로드 중인 항목 없음", inline=False)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
|
||||
bot.tree.add_command(anime_group)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 기존 ! 명령어 (유지, 하위호환)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@bot.command(name="ping", help="봇 응답 테스트")
|
||||
async def ping_command(ctx: commands.Context):
|
||||
latency = round(bot.latency * 1000)
|
||||
|
||||
Reference in New Issue
Block a user