Files
variet-agent/api/discord_bot.py
CD 3618387b8e feat(anime): 자막/토렌트 파이프라인 대폭 개선
- Blogspot Atom Feed API로 전체 에피소드 자막 URL 발견
- AniList prequel 체인 기반 시즌 에피소드 오프셋 자동 감지
- Nyaa S-tag 감지 → 절대/시즌 번호 체계 자동 판별
- 기존 자막 에피소드 스킵 (URL 페치 전 pre-skip)
- 오프셋 적용 자막 리네임 (시즌번호→절대번호 매칭)
- ASW HEVC 토렌트 우선 정렬 (truncation 방지)
- 토렌트 완료 대기 → 자동 삭제 라이프사이클
- 중복 자막 자동 삭제
- .smi 자막 확장자 지원
2026-03-15 18:23:57 +09:00

1842 lines
71 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Discord Bot — 워크스페이스 기반 AI Agent.
슬래시 커맨드로 워크스페이스 관리.
등록된 채널에서 자동 대화 + 통합 프롬프트 (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
from discord.ext import commands
import config
from core.workspace import WorkspaceManager
from core.gemini_caller import GeminiCaller, GeminiCallError
logger = logging.getLogger("variet.discord")
EMBED_DESC_LIMIT = 4096
EMBED_FIELD_LIMIT = 1024
async def safe_send_embed(channel, embed: discord.Embed):
"""Embed가 Discord 제한을 초과하면 나눠서 전송."""
# description이 길면 분할
desc = embed.description or ""
if len(desc) <= EMBED_DESC_LIMIT:
await channel.send(embed=embed)
return
# 첫 번째: 원래 embed + 잘린 description
chunks = [desc[i:i+EMBED_DESC_LIMIT] for i in range(0, len(desc), EMBED_DESC_LIMIT)]
embed.description = chunks[0]
await channel.send(embed=embed)
# 나머지: 연속 embed
for chunk in chunks[1:]:
cont = discord.Embed(description=chunk, color=embed.color)
await channel.send(embed=cont)
# Bot 설정
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(
command_prefix=config.DISCORD_COMMAND_PREFIX,
intents=intents,
help_command=commands.DefaultHelpCommand(no_category="명령어"),
)
# 워크스페이스 매니저 (전역)
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
# ──────────────────────────────────────────────
# 대화 기억
# ──────────────────────────────────────────────
async def _get_channel_history(channel: discord.TextChannel, limit: int = 10) -> str:
"""채널 최근 메시지를 대화 히스토리 문자열로 변환."""
messages = []
async for msg in channel.history(limit=limit + 1):
role = "assistant" if msg.author.bot else "user"
# 텍스트 내용
content = msg.content[:300] if msg.content else ""
# Embed 내용도 포함 (봇의 clarify 질문 등)
if msg.embeds:
embed_parts = []
for embed in msg.embeds:
if embed.title:
embed_parts.append(embed.title)
if embed.description:
embed_parts.append(embed.description[:200])
for field in embed.fields:
embed_parts.append(f"{field.name}: {field.value[:100]}")
if embed_parts:
embed_text = " | ".join(embed_parts)
content = f"{content} {embed_text}".strip() if content else embed_text
if content:
messages.append(f"[{role}] {content}")
messages.reverse()
if messages:
messages = messages[:-1] # 현재 메시지 제외
if not messages:
return ""
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)
# ──────────────────────────────────────────────
# 에이전트 호출 (MCP 도구 자동 사용)
# ──────────────────────────────────────────────
async def _agent_call(text: str, history: str, project_path: str) -> str:
"""Gemini CLI 에이전트 모드로 호출 — MCP 도구를 자율적으로 사용.
분류(chat/task/anime) 없이 에이전트가 직접 판단하여
도구를 사용하거나 바로 답변합니다.
"""
gemini = GeminiCaller(project_path)
context = (
f"{history}"
f"## Workspace\nPath: {project_path}\n\n"
f"## User Message\n{text}"
)
response = await gemini.call_agent(
"agent", context, cwd=project_path, timeout=1200,
)
return response
# ──────────────────────────────────────────────
# 이벤트 핸들러
# ──────────────────────────────────────────────
@bot.event
async def on_ready():
"""봇 접속 완료 — 슬래시 커맨드 동기화."""
logger.info(f"Discord Bot 접속 완료: {bot.user} (ID: {bot.user.id})")
logger.info(f"서버 {len(bot.guilds)}개 연결됨")
# 슬래시 커맨드 동기화 (길드별 = 즉시 반영)
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}")
# 유령 워크스페이스 정리
all_channel_ids = set()
for guild in bot.guilds:
for channel in guild.text_channels:
all_channel_ids.add(channel.id)
orphans = ws_manager.cleanup_orphans(all_channel_ids)
if orphans:
names = ", ".join(ws.name for ws in orphans)
logger.info(f"유령 워크스페이스 {len(orphans)}개 보존(이름 변경): {names}")
# 등록된 워크스페이스 표시
ws_list = ws_manager.list_all()
if ws_list:
for ws in ws_list:
logger.info(f"워크스페이스 활성: #{ws.channel_id} -> {ws.name} ({ws.path})")
else:
logger.info("등록된 워크스페이스 없음 - /workspace set 으로 등록하세요")
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.listening,
name="/workspace",
)
)
@bot.event
async def on_guild_channel_update(before, after):
"""채널 이름 변경 감지 -> 워크스페이스 자동 이름 변경."""
if before.name == after.name:
return
if not ws_manager.is_workspace_channel(after.id):
return
ws = ws_manager.get_workspace(after.id)
old_name = ws.name
new_name = after.name
success, new_path = ws_manager.rename_workspace(after.id, new_name)
if success:
embed = discord.Embed(
title="📝 워크스페이스 자동 업데이트",
description=(
f"채널 이름 변경 감지: `{old_name}` -> `{new_name}`\n\n"
f"워크스페이스 이름: **{new_name}**\n"
f"경로: `{new_path}`"
),
color=0x3498DB,
)
await after.send(embed=embed)
else:
logger.warning(f"채널 이름 변경 시 워크스페이스 업데이트 실패: {new_path}")
@bot.event
async def on_message(message: discord.Message):
"""메시지 수신 — 워크스페이스 채널이면 자동 응답."""
if message.author == bot.user or message.author.bot:
return
# ! 명령어 처리
if message.content.startswith(config.DISCORD_COMMAND_PREFIX):
await bot.process_commands(message)
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
# 취소 명령어 확인
cancel_keywords = {"취소", "stop", "cancel", "중지", "멈춰"}
if user_text.lower() in cancel_keywords:
channel_id = message.channel.id
if channel_id in _running_tasks and not _running_tasks[channel_id].done():
_running_tasks[channel_id].cancel()
del _running_tasks[channel_id]
await message.reply(
embed=discord.Embed(
title="🛑 작업 취소됨",
description="실행 중인 작업을 취소했습니다.",
color=0xE74C3C,
)
)
else:
await message.reply("실행 중인 작업이 없습니다.")
return
# 에이전트 호출 (MCP 도구 자동 사용)
channel_id = message.channel.id
if channel_id in _running_tasks and not _running_tasks[channel_id].done():
await message.reply("⚠️ 이미 작업이 실행 중입니다. `취소` 후 다시 요청하세요.")
return
async def _tracked_agent():
progress_msg = None
try:
# 진행 표시
progress_msg = await message.channel.send(
embed=discord.Embed(
title="🤖 에이전트 처리 중...",
description=f"```{user_text[:200]}```",
color=0xF39C12,
)
)
async with message.channel.typing():
# 1단계: NLU 빠른 분류 (Gemini text 모드, ~5초)
intent = await _classify_intent(user_text, ws.path)
logger.info(f"NLU 분류: '{user_text[:50]}'{intent}")
# 2단계: anime 의도면 직접 실행 (Gemini agent 우회)
if intent and intent.get("mode") == "anime":
if progress_msg:
await progress_msg.edit(embed=discord.Embed(
title="📦 애니 작업 실행 중...",
description=f"```{user_text[:200]}```",
color=0x3498DB,
))
await _handle_anime(message, intent)
if progress_msg:
try: await progress_msg.delete()
except Exception: pass
return
# 2-2단계: research 의도면 조사+위키 등록
if intent and intent.get("mode") == "research":
if progress_msg:
await progress_msg.edit(embed=discord.Embed(
title="🔍 리서치 진행 중...",
description=f"주제: **{intent.get('topic', user_text[:50])}**",
color=0x9B59B6,
))
await _handle_research(message, intent, ws.path)
if progress_msg:
try: await progress_msg.delete()
except Exception: pass
return
# 3단계: 일반 → Gemini agent 모드
history = await _get_channel_history(message.channel, limit=10)
response = await _agent_call(user_text, history, ws.path)
logger.info(f"에이전트 응답: \"{user_text[:50]}\" -> {len(response)}")
# 진행 메시지 삭제
if progress_msg:
try:
await progress_msg.delete()
except Exception:
pass
if not response:
await message.reply("응답을 생성하지 못했습니다.")
return
# 응답 전송
if len(response) <= 2000:
await message.reply(response)
else:
for i in range(0, len(response), 4000):
chunk = response[i:i + 4000]
embed = discord.Embed(description=chunk, color=0x3498DB)
await message.channel.send(embed=embed)
except asyncio.CancelledError:
await message.channel.send(
embed=discord.Embed(
title="🛑 작업 취소됨",
description="작업이 사용자에 의해 취소되었습니다.",
color=0xE74C3C,
)
)
except GeminiCallError as e:
await message.reply(f"⚠️ AI 호출 오류: {str(e)[:200]}")
except Exception as e:
logger.error(f"에이전트 호출 오류: {e}", exc_info=True)
await message.reply(f"❌ 오류: {str(e)[:200]}")
finally:
_running_tasks.pop(channel_id, None)
task = asyncio.create_task(_tracked_agent())
_running_tasks[channel_id] = task
# ──────────────────────────────────────────────
# NLU 빠른 분류 (Gemini text 모드)
# ──────────────────────────────────────────────
_NLU_PROMPT = """\
사용자 메시지를 분류하여 JSON으로 응답하세요. 반드시 JSON만 출력.
분류:
- 애니메이션 다운로드/검색/상태 → mode: "anime"
- 조사+위키 등록/정리 요청 → mode: "research"
- 그 외 → mode: "chat"
anime 필드: action ("batch"|"download"|"search"|"status"|"schedule"|"list"), title, episode, filter
research 필드:
- action: "research" (조사+등록), "organize" (기존 정리), "update" (페이지 수정)
- topic: 주제
예시:
"이번분기 애니 자막 다운받아줘"{"mode":"anime","action":"batch","filter":"자막"}
"FX Forward 조사해서 위키에 정리해줘"{"mode":"research","action":"research","topic":"FX Forward"}
"위키에 서버 관련 내용 정리해줘"{"mode":"research","action":"organize","topic":"서버"}
"GraphQL이 뭐야?"{"mode":"chat"}
"""
async def _classify_intent(text: str, project_path: str) -> dict | None:
"""Gemini text 모드로 빠른 의도 분류 (~5초)."""
try:
gemini = GeminiCaller(project_path)
raw = await gemini.call("unified", f"{_NLU_PROMPT}\n\n사용자: {text}", timeout=30)
import json as _json
m = re.search(r'\{[^}]+\}', raw, re.DOTALL)
if m:
return _json.loads(m.group(0))
except Exception as e:
logger.warning(f"NLU 분류 실패 (agent fallback): {e}")
return None
# ──────────────────────────────────────────────
# Anime 핸들러 (NLU 분류 결과로 직접 실행)
# ──────────────────────────────────────────────
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 == "batch":
await _anime_batch_direct(message, filter_str or "자막 배치")
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):
"""필터 기반 복수 애니 다운로드 — batch_download() 사용."""
await _anime_batch_direct(message, f"{filter_str} {action}")
async def _anime_batch_direct(message, user_text: str):
"""애니 배치 다운로드 — Gemini 없이 직접 실행."""
from tools.anime_pipeline import AnimePipeline
t = user_text.lower()
mode = "auto"
if "자막만" in t or "sub_only" in t:
mode = "sub_only"
elif "영상만" in t or "video_only" in t:
mode = "video_only"
sub_filter = "자막" in t or "sub:yes" in t # 자막 언급 시 자막 필터 활성
embed = discord.Embed(
title="📦 배치 다운로드 시작",
description=f"모드: `{mode}` | 자막 필터: `{'ON' if sub_filter else 'OFF'}`\n⏳ NAS 스캔 + Anissia 로딩 중...",
color=0xF39C12,
)
status_msg = await message.channel.send(embed=embed)
pipeline = AnimePipeline()
try:
results = await pipeline.batch_download(mode=mode, sub_filter=sub_filter)
except Exception as e:
logger.error(f"배치 다운로드 오류: {e}", exc_info=True)
embed.title = "❌ 배치 다운로드 실패"
embed.description = str(e)[:500]
embed.color = 0xE74C3C
await status_msg.edit(embed=embed)
return
# 결과 정리
success = [r for r in results if r.success]
lines = []
for r in results:
icon = "" if r.success else ""
title = r.anime.subject if r.anime else "?"
detail = ""
if r.torrent_added:
detail += " 🎬토렌트추가"
if r.subtitles:
detail += f" 📝자막{len(r.subtitles)}"
if r.errors:
detail += f" ⚠️{r.errors[0][:50]}"
lines.append(f"{icon} {title}{detail}")
embed.title = f"📊 배치 결과: {len(success)}/{len(results)}건 성공"
embed.description = "\n".join(lines) or "처리할 애니가 없습니다."
embed.color = 0x2ECC71 if success else 0xF39C12
await status_msg.edit(embed=embed)
# ──────────────────────────────────────────────
# Research 핸들러 (조사+위키 등록 / 기존 정리)
# ──────────────────────────────────────────────
async def _handle_research(message: discord.Message, parsed: dict, project_path: str):
"""리서치 요청 처리 — 조사 후 위키 등록 또는 기존 위키 정리."""
from tools.wiki_client import WikiClient
action = parsed.get("action", "research")
topic = parsed.get("topic", "")
wiki = WikiClient()
if not topic:
await message.reply("🔍 어떤 주제를 조사할까요?")
return
try:
if action == "research":
await _research_and_publish(message, topic, wiki, project_path)
elif action == "organize":
await _organize_wiki(message, topic, wiki, project_path)
elif action == "update":
await _research_and_publish(message, topic, wiki, project_path)
else:
await message.reply(f"❓ 알 수 없는 리서치 액션: {action}")
except Exception as e:
logger.error(f"리서치 핸들러 오류: {e}", exc_info=True)
await message.reply(f"❌ 리서치 오류: {str(e)[:300]}")
async def _research_and_publish(
message: discord.Message, topic: str,
wiki, project_path: str,
):
"""주제 조사 → 위키 페이지 등록."""
from tools.wiki_client import WikiClient
status_msg = await message.channel.send(
embed=discord.Embed(
title="🔍 조사 중...",
description=f"**{topic}**\n\nGemini가 웹 검색 + 자료를 수집하고 있습니다.",
color=0x9B59B6,
)
)
# Gemini agent로 조사 (google_web_search 자동 사용)
gemini = GeminiCaller(project_path)
research_prompt = (
f"다음 주제에 대해 깊이 있게 조사하고, 위키 페이지용 마크다운으로 정리하세요.\n\n"
f"주제: {topic}\n\n"
f"요구사항:\n"
f"1. 반드시 웹 검색을 통해 최신 정보를 확인하세요\n"
f"2. 핵심 개념, 장단점, 실무 활용법을 포함하세요\n"
f"3. 출처 URL을 반드시 포함하세요\n"
f"4. 마크다운 형식으로 작성하세요 (# 제목부터)\n"
f"5. 한국어로 작성하세요\n"
)
try:
content = await gemini.call_agent(
"agent", research_prompt, cwd=project_path, timeout=300,
)
except GeminiCallError as e:
await status_msg.edit(embed=discord.Embed(
title="❌ 조사 실패", description=str(e)[:500], color=0xE74C3C,
))
return
if not content or len(content) < 50:
await status_msg.edit(embed=discord.Embed(
title="⚠️ 결과 부족", description="충분한 정보를 수집하지 못했습니다.",
color=0xF39C12,
))
return
# 위키 등록
slug = WikiClient.slugify(topic)
path = f"research/{slug}"
page = await wiki.upsert_page(path, topic, content, description=f"리서치: {topic}")
# 대시보드 갱신
await wiki.update_dashboard()
await status_msg.edit(embed=discord.Embed(
title=f"✅ 위키 등록 완료: {topic}",
description=(
f"📄 [{path}](https://wiki.variet.net/{path})\n"
f"📊 대시보드 갱신됨\n"
f"📝 {len(content)}자 작성"
),
color=0x2ECC71,
))
async def _organize_wiki(
message: discord.Message, topic: str,
wiki, project_path: str,
):
"""기존 위키 페이지들을 주제별로 재정리."""
status_msg = await message.channel.send(
embed=discord.Embed(
title="📋 위키 분석 중...",
description=f"**{topic}** 관련 페이지를 수집하고 있습니다.",
color=0x9B59B6,
)
)
# 관련 페이지 수집
all_pages = await wiki.list_pages()
topic_lower = topic.lower()
related = [p for p in all_pages if topic_lower in p.title.lower() or topic_lower in p.path.lower()]
if not related:
await status_msg.edit(embed=discord.Embed(
title="⚠️ 관련 페이지 없음",
description=f"'{topic}' 관련 위키 페이지를 찾지 못했습니다.",
color=0xF39C12,
))
return
# 각 페이지 내용 수집
contents = []
for p in related:
full_page = await wiki.get_page(p.id)
contents.append(f"## 페이지: {full_page.title} (/{full_page.path})\n{full_page.content}\n")
combined = "\n---\n".join(contents)
await status_msg.edit(embed=discord.Embed(
title="🔄 재정리 중...",
description=f"{len(related)}개 페이지를 Gemini가 통합 정리합니다.",
color=0x9B59B6,
))
# Gemini로 통합 정리
gemini = GeminiCaller(project_path)
organize_prompt = (
f"다음은 '{topic}' 관련 기존 위키 페이지들입니다.\n"
f"이 내용을 하나의 통합된 위키 페이지로 재정리하세요.\n\n"
f"요구사항:\n"
f"1. 중복 제거, 논리적 구조화\n"
f"2. 누락된 정보가 있으면 웹 검색으로 보완\n"
f"3. 마크다운 형식, 한국어\n\n"
f"=== 기존 페이지들 ===\n{combined[:15000]}"
)
try:
content = await gemini.call_agent(
"agent", organize_prompt, cwd=project_path, timeout=300,
)
except GeminiCallError as e:
await status_msg.edit(embed=discord.Embed(
title="❌ 정리 실패", description=str(e)[:500], color=0xE74C3C,
))
return
# 통합 페이지 등록
from tools.wiki_client import WikiClient
slug = WikiClient.slugify(topic)
path = f"research/{slug}"
page = await wiki.upsert_page(path, f"{topic} (통합)", content, description=f"통합 정리: {topic}")
await wiki.update_dashboard()
await status_msg.edit(embed=discord.Embed(
title=f"✅ 위키 정리 완료: {topic}",
description=(
f"📄 [{path}](https://wiki.variet.net/{path})\n"
f"🔗 원본 {len(related)}개 → 통합 1개\n"
f"📝 {len(content)}"
),
color=0x2ECC71,
))
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)
# ──────────────────────────────────────────────
# 설정 경고
# ──────────────────────────────────────────────
async def _send_setup_warning(message: discord.Message, ws):
"""미설정 항목 안내."""
missing = ws.missing_configs
lines = []
for item in missing:
if item == "Git":
lines.append("❌ **Git** 미설정 → `/workspace git` 으로 설정")
elif item == "Vikunja":
lines.append("❌ **Vikunja** 미설정 → `/workspace vikunja` 으로 설정")
embed = discord.Embed(
title="⚠️ 워크스페이스 설정 미완료",
description=(
f"**{ws.name}** 워크스페이스의 설정이 완료되지 않아 작업을 실행할 수 없습니다.\n\n"
+ "\n".join(lines)
+ "\n\n설정 완료 후 다시 요청해주세요."
),
color=0xE74C3C,
)
await message.reply(embed=embed)
# ──────────────────────────────────────────────
# Task 핸들러 (파이프라인 실행)
# ──────────────────────────────────────────────
async def _handle_task(message: discord.Message, text: str, ws):
"""작업 요청 — 파이프라인 단계별 실행 + 진행 표시."""
import uuid
task_id = uuid.uuid4().hex[:8]
embed = discord.Embed(
title="📋 작업 접수",
description=f"```{text[:200]}```",
color=0x3498DB,
)
embed.set_footer(text=f"ID: {task_id} | {ws.name}")
status_msg = await message.channel.send(embed=embed)
try:
from core.task_pipeline import TaskPipeline, MAX_REVIEW_RETRIES
pipeline = TaskPipeline(
project_path=ws.path,
docs_subpath=ws.docs_path,
)
pipeline.setup()
# 1. Plan (direct 모드면 Planner가 직접 처리)
embed.title = "🔍 분석 중..."
embed.color = 0xF39C12
await status_msg.edit(embed=embed)
plan = await pipeline.plan(text)
# ── Direct 모드: Planner가 직접 처리 완료 ──
is_direct = plan.get("direct", False)
if isinstance(is_direct, str):
is_direct = is_direct.lower() in ("true", "yes")
if is_direct:
result_text = plan.get("result", plan.get("summary", "완료"))
direct_embed = discord.Embed(
title="" + plan.get("summary", "처리 완료"),
description=result_text[:2000],
color=0x2ECC71,
)
await safe_send_embed(message.channel, direct_embed)
# Reviewer 검증
review_embed = discord.Embed(
title="🔍 리뷰어 검토 중...",
color=0xF39C12,
)
review_msg = await message.channel.send(embed=review_embed)
direct_tasks = [{"title": plan.get("summary", "직접 처리"), "description": result_text}]
review = await pipeline.batch_review(direct_tasks, [result_text])
passed = review.get("passed", True)
if isinstance(passed, str):
passed = passed.lower() in ("true", "yes", "pass")
review_embed.title = f"{'' if passed else '⚠️'} 리뷰 결과"
review_embed.description = review.get("summary", str(review))[:500]
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
review_embed.set_footer(text=f"ID: {task_id} | {ws.name} | direct")
await review_msg.edit(embed=review_embed)
pipeline.docs.record_session(text, {"summary": result_text}, plan)
return
# ── Tasks 모드: Coder에게 분배 ──
tasks = plan.get("tasks", [])
if tasks:
task_list = "\n".join(
f"{t.get('title', t.get('description', '?'))}"
for t in tasks[:10]
)
plan_embed = discord.Embed(
title="📝 작업 계획",
description=plan.get("summary", "")[:500],
color=0x2ECC71,
)
plan_embed.add_field(
name=f"태스크 ({len(tasks)}개)", value=task_list[:1000], inline=False,
)
await message.channel.send(embed=plan_embed)
else:
# 태스크가 없지만 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 오케스트레이션 루프 (외부: 리뷰어, 내부: 자가검증)
MAX_PLANNER_LOOPS = 3 # Planner 내부 자가검증 반복 제한
review = None
all_code_outputs = []
for review_attempt in range(1 + MAX_REVIEW_RETRIES):
review_label = f" (리뷰 재시도 {review_attempt})" if review_attempt > 0 else ""
# 리뷰어 반려 시: 피드백으로 재계획
if review_attempt > 0:
feedback = review.get("summary", str(review))
await message.channel.send(
embed=discord.Embed(
title=f"🔄 리뷰어 피드백 반영 재계획",
description=feedback[:500],
color=0xE74C3C,
)
)
replan_request = (
f"## 원래 요청\n{text}\n\n"
f"## 리뷰어 피드백 (반드시 반영)\n{feedback}\n\n"
f"피드백을 분석하고 태스크를 재설계하세요."
)
plan = await pipeline.plan(replan_request)
tasks = plan.get("tasks", [])
if not tasks:
break
# ── Planner 내부 루프: 계획 → 코딩 → 자가검증 → 추가작업 ──
for planner_round in range(MAX_PLANNER_LOOPS):
round_label = f" (보완 {planner_round})" if planner_round > 0 else ""
# 코딩
code_embed = discord.Embed(
title=f"⚙️ 코딩 중...{review_label}{round_label} ({len(tasks)}개)",
description="\n".join(
f"{t.get('title', '?')[:60]}" for t in tasks[:10]
),
color=0xE67E22,
)
code_msg = await message.channel.send(embed=code_embed)
code_outputs = await pipeline.code_parallel(tasks)
all_code_outputs = code_outputs # 최신 결과 유지
error_count = sum(1 for o in code_outputs if o.startswith("[ERROR]"))
code_embed.title = f"✅ 코딩 완료{round_label} ({len(tasks) - error_count}/{len(tasks)})"
code_embed.color = 0x2ECC71 if error_count == 0 else 0xF39C12
await code_msg.edit(embed=code_embed)
# Planner 자가검증
verify_embed = discord.Embed(
title="🔍 Planner 자가 검증 중...",
color=0xF39C12,
)
verify_msg = await message.channel.send(embed=verify_embed)
verification = await pipeline.planner_verify(text, plan, code_outputs)
satisfied = verification.get("satisfied", True)
if isinstance(satisfied, str):
satisfied = satisfied.lower() in ("true", "yes")
verify_embed.title = f"{'' if satisfied else '🔄'} Planner 검증{round_label}"
verify_embed.description = verification.get("feedback", "")[:500]
verify_embed.color = 0x2ECC71 if satisfied else 0xF39C12
await verify_msg.edit(embed=verify_embed)
if satisfied:
break # Planner 만족 → 리뷰어에게 전달
# 추가 태스크가 있으면 계속
additional = verification.get("additional_tasks", [])
if additional:
tasks = additional
task_list = "\n".join(f"{t.get('title', '?')}" for t in tasks[:10])
await message.channel.send(
embed=discord.Embed(
title=f"📝 추가 작업 {len(additional)}",
color=0xF39C12,
).add_field(name="태스크", value=task_list[:1000], inline=False)
)
else:
break # 추가 태스크 없으면 종료
# ── 외부 리뷰어 ──
review_embed = discord.Embed(
title="🔍 리뷰어 검토 중...",
color=0xF39C12,
)
review_msg = await message.channel.send(embed=review_embed)
review = await pipeline.batch_review(tasks, all_code_outputs)
passed = review.get("passed", True)
if isinstance(passed, str):
passed = passed.lower() in ("true", "yes", "pass")
review_embed.title = f"{'' if passed else '⚠️'} 리뷰어 결과{review_label}"
review_embed.description = review.get("summary", str(review))[:500]
review_embed.color = 0x2ECC71 if passed else 0xE74C3C
await review_msg.edit(embed=review_embed)
if passed:
break
# 3. 총평
summary = await pipeline.summarize(text, plan, all_code_outputs, review)
summary_embed = discord.Embed(
title=f"📊 {summary.get('title', '작업 완료')}",
description=summary.get("summary", "완료"),
color=0x9B59B6,
)
for field_name, key in [
("변경 사항", "changes"),
("⚠️ 주의", "warnings"),
("🔜 다음 단계", "next_steps"),
]:
items = summary.get(key, [])
if items:
if key == "changes" and isinstance(items[0], dict):
val = "\n".join(
f"• `{c.get('file','?')}` - {c.get('description','')}"
for c in items[:10]
)
else:
val = "\n".join(f"{s}" for s in items)
summary_embed.add_field(name=field_name, value=val[:1000], inline=False)
summary_embed.set_footer(text=f"ID: {task_id} | {ws.name}")
await safe_send_embed(message.channel, summary_embed)
# 기록
pipeline.docs.record_session(text, summary, plan)
pipeline.docs.append_changelog(summary.get("title", text[:50]))
except GeminiCallError as e:
await message.channel.send(
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)[:300]}```\n\n"
f"💡 다시 요청하시거나, 문제가 계속되면 관리자에게 문의하세요."
),
color=0xE74C3C,
)
)
# ──────────────────────────────────────────────
# 슬래시 커맨드: /workspace
# ──────────────────────────────────────────────
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 = ""):
"""채널에 워크스페이스 등록."""
from pathlib import Path as P
# 이름 미입력 시 채널 이름 사용
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 P(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"):
"""워크스페이스에 Git 설정."""
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):
"""워크스페이스에 Vikunja 설정."""
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)
# ──────────────────────────────────────────────
# /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 = ""
response = await _agent_call(request_text, history, ws.path)
logger.info(f"[스레드] 에이전트 응답: \"{request_text[:50]}\" -> {len(response)}")
if 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)
else:
await thread.send("응답을 생성하지 못했습니다.")
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)
await ctx.send(f"🏓 Pong! ({latency}ms)")
@bot.command(name="info", help="시스템 정보")
async def info_command(ctx: commands.Context):
embed = discord.Embed(
title="🤖 Variet Agent",
description="AI Agent Team — 워크스페이스 기반 자동화 개발 에이전트",
color=0x9B59B6,
)
all_ws = ws_manager.list_all()
embed.add_field(name="워크스페이스", value=f"{len(all_ws)}개 등록", inline=True)
embed.add_field(name="서버", value=str(len(bot.guilds)), inline=True)
embed.add_field(
name="파이프라인",
value="통합분류(1회) → Code(병렬) → Review(배치) → 총평 → 기록",
inline=False,
)
await ctx.send(embed=embed)
async def start_bot():
"""Discord Bot 시작."""
token = config.DISCORD_BOT_TOKEN
if not token:
logger.error("DISCORD_BOT_TOKEN이 설정되지 않았습니다.")
return
logger.info("Discord Bot 시작 중...")
try:
await bot.start(token)
except discord.LoginFailure:
logger.error("Discord 로그인 실패 — 토큰을 확인하세요.")
except Exception as e:
logger.error(f"Discord Bot 오류: {e}")
async def stop_bot():
"""Discord Bot 정지."""
if not bot.is_closed():
await bot.close()