Files
variet-agent/api/discord_bot.py

1596 lines
62 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=300,
)
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():
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
# ──────────────────────────────────────────────
# 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)
# ──────────────────────────────────────────────
# 설정 경고
# ──────────────────────────────────────────────
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()