feat(nextcloud): Nextcloud 4모듈 + NC핸들러 + AI Foreman v0.1

- tools/nextcloud_client.py: WebDAV/OCS/CalDAV/CardDAV 공통 클라이언트
- tools/nc_files.py: 파일 검색/목록/최근/공유링크
- tools/nc_calendar.py: CalDAV 일정 CRUD + ICS 빌더
- tools/nc_mail.py: IMAP 메일 조회 (PLAIN auth for Mailcow)
- tools/nc_contacts.py: CardDAV 연락처 + EasyOCR 명함 스캔
- handlers/nc_handler.py: 자연어→NC도구 자동 라우팅
- core/foreman.py: 목표 분해 + 상담 세션 + Vikunja 등록
- prompts/foreman.md: Foreman 시스템 프롬프트
- prompts/unified.md: nextcloud 모드 분류 추가
- config.py: .env 따옴표 파싱 버그 수정
- api/discord_bot.py: /goal 커맨드 + Foreman 스레드 라우팅
This commit is contained in:
2026-03-18 17:25:27 +09:00
parent aae9c188eb
commit d22493125c
14 changed files with 2709 additions and 2 deletions

View File

@@ -19,9 +19,17 @@ from discord.ext import commands
import config
from core.workspace import WorkspaceManager
from core.gemini_caller import GeminiCaller, GeminiCallError
from core.foreman import Foreman
from handlers.nc_handler import NCHandler
logger = logging.getLogger("variet.discord")
# Nextcloud 도구 핸들러
_nc_handler = NCHandler()
# AI Foreman (목표 분해)
_foreman = Foreman()
EMBED_DESC_LIMIT = 4096
EMBED_FIELD_LIMIT = 1024
@@ -263,7 +271,48 @@ async def on_message(message: discord.Message):
if not user_text:
return
# 취소 명령어 확인
# ──────────────────────────────────────
# Foreman 세션 스레드 확인
# ──────────────────────────────────────
foreman_session = _foreman.get_session(message.channel.id)
if foreman_session:
async def _foreman_reply():
try:
async with message.channel.typing():
# ! 명령어 처리
if user_text.startswith("!"):
parts = user_text[1:].split(maxsplit=1)
command = parts[0] if parts else ""
args = parts[1] if len(parts) > 1 else ""
response = await _foreman.handle_command(
foreman_session, command, args,
)
else:
# 자유 형식 대화
response = await _foreman.handle_freeform(
foreman_session, user_text,
)
if response:
if len(response) <= 2000:
await message.reply(response)
else:
for i in range(0, len(response), 4000):
embed = discord.Embed(
description=response[i:i + 4000],
color=0x9B59B6,
)
await message.channel.send(embed=embed)
except Exception as e:
logger.error(f"Foreman 오류: {e}", exc_info=True)
await message.reply(f"⚠️ 오류: {str(e)[:200]}")
asyncio.create_task(_foreman_reply())
return
# ──────────────────────────────────────
# 취소 명령어 확인
# ──────────────────────────────────────
cancel_keywords = {"취소", "stop", "cancel", "중지", "멈춰"}
if user_text.lower() in cancel_keywords:
channel_id = message.channel.id
@@ -975,6 +1024,63 @@ async def anime_status(interaction: discord.Interaction):
bot.tree.add_command(anime_group)
# ──────────────────────────────────────────────
# /goal 커맨드 — AI Foreman 목표 분해
# ──────────────────────────────────────────────
@bot.tree.command(name="goal", description="목표를 입력하면 AI가 작업 트리로 분해합니다")
@app_commands.describe(goal="달성할 목표 (자연어)")
async def goal_command(interaction: discord.Interaction, goal: str):
"""Foreman 상담 모드 시작."""
await interaction.response.defer()
# 스레드 생성
thread_name = f"🎯 {goal[:80]}"
thread = await interaction.channel.create_thread(
name=thread_name,
type=discord.ChannelType.public_thread,
auto_archive_duration=1440,
)
# 세션 생성
session = _foreman.create_session(goal, thread.id, interaction.user.id)
# 시작 메시지
start_embed = discord.Embed(
title="🎯 AI Foreman — 목표 분해",
description=(
f"**목표:** {goal}\n\n"
f"작업 트리를 생성 중... ⏳"
),
color=0x9B59B6,
)
await thread.send(embed=start_embed)
await interaction.followup.send(f"✅ 상담 스레드가 생성되었습니다: <#{thread.id}>", ephemeral=True)
# Gemini로 목표 분해
try:
tasks = await _foreman.decompose_goal(session)
if tasks:
tree_display = "\n".join(t.to_display() for t in tasks)
total = sum(len(t.to_flat_list()) for t in tasks)
result_embed = discord.Embed(
title="📋 작업 트리 (초안)",
description=tree_display[:4000],
color=0x2ECC71,
)
result_embed.set_footer(
text=f"{total}개 작업 | !확정 !수정 !추가 !삭제 !현황"
)
await thread.send(embed=result_embed)
else:
await thread.send("⚠️ 작업 분해에 실패했습니다. 목표를 더 구체적으로 입력해주세요.")
except GeminiCallError as e:
await thread.send(f"⚠️ AI 호출 오류: {str(e)[:300]}")
except Exception as e:
logger.error(f"Foreman 분해 오류: {e}", exc_info=True)
await thread.send(f"❌ 오류: {str(e)[:200]}")
# ──────────────────────────────────────────────
# 기존 ! 명령어 (유지, 하위호환)
# ──────────────────────────────────────────────