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

301
handlers/nc_handler.py Normal file
View File

@@ -0,0 +1,301 @@
r"""Nextcloud 도구 핸들러 — 자연어 → NC 도구 자동 라우팅.
Gemini unified prompt의 'nextcloud' 모드 분류 결과를 받아
적절한 NC 모듈 함수를 호출하고 Discord embed로 응답.
"""
import logging
import re
from datetime import datetime, timedelta
from typing import Optional
import discord
from tools.nc_files import NCFilesClient
from tools.nc_calendar import NCCalendarClient
from tools.nc_mail import NCMailClient
logger = logging.getLogger("variet.handlers.nc")
class NCHandler:
"""Nextcloud 도구 라우터."""
def __init__(self):
self.files = NCFilesClient()
self.calendar = NCCalendarClient()
self.mail = NCMailClient()
# Contacts는 EasyOCR 무거움 → 필요 시 지연 로드
self._contacts = None
@property
def contacts(self):
if self._contacts is None:
from tools.nc_contacts import NCContactsClient
self._contacts = NCContactsClient()
return self._contacts
# ──────────────────────────────────────
# 메인 라우터
# ──────────────────────────────────────
async def handle(self, action: dict, channel) -> None:
"""unified prompt 결과를 받아 NC 도구 실행 + 응답.
Args:
action: {"tool": "files|calendar|mail|contacts", "op": ..., "params": {...}}
channel: Discord 채널/스레드
"""
tool = action.get("tool", "")
op = action.get("op", "")
params = action.get("params", {})
try:
if tool == "files":
await self._handle_files(op, params, channel)
elif tool == "calendar":
await self._handle_calendar(op, params, channel)
elif tool == "mail":
await self._handle_mail(op, params, channel)
elif tool == "contacts":
await self._handle_contacts(op, params, channel)
else:
await channel.send(
embed=discord.Embed(
title="❌ 알 수 없는 도구",
description=f"tool: `{tool}`은 지원하지 않습니다.",
color=0xE74C3C,
)
)
except Exception as e:
logger.error(f"NC 핸들러 오류: {tool}.{op}{e}", exc_info=True)
await channel.send(
embed=discord.Embed(
title="❌ 오류 발생",
description=f"`{tool}.{op}` 실행 중 오류:\n```{str(e)[:300]}```",
color=0xE74C3C,
)
)
# ──────────────────────────────────────
# Files
# ──────────────────────────────────────
async def _handle_files(self, op: str, params: dict, channel):
if op == "search":
query = params.get("query", "")
files = await self.files.search(query)
if not files:
await channel.send(f"🔍 `{query}` 검색 결과가 없습니다.")
return
lines = [f"{i}. {f.icon} **{f.name}** ({f.size_human})\n 📂 `{f.path}`"
for i, f in enumerate(files[:15], 1)]
embed = discord.Embed(
title=f"🔍 파일 검색: {query}",
description="\n".join(lines),
color=0x3498DB,
)
embed.set_footer(text=f"{len(files)}건 중 최대 15건 표시")
await channel.send(embed=embed)
elif op == "list":
path = params.get("path", "")
files = await self.files.list_dir(path)
lines = [f"{f.icon} **{f.name}**" + (f" ({f.size_human})" if not f.is_dir else "")
for f in files[:20]]
embed = discord.Embed(
title=f"📂 /{path or '(루트)'}",
description="\n".join(lines) or "빈 디렉토리",
color=0x3498DB,
)
await channel.send(embed=embed)
elif op == "link":
path = params.get("path", "")
url = await self.files.create_link(path)
if url:
await channel.send(f"🔗 공유 링크: {url}")
else:
await channel.send("❌ 공유 링크 생성 실패")
elif op == "recent":
limit = params.get("limit", 10)
files = await self.files.list_recent(limit)
lines = [f"{i}. {f.icon} **{f.name}** ({f.size_human})\n 📅 {f.lastmod}"
for i, f in enumerate(files, 1)]
embed = discord.Embed(
title="🕐 최근 파일",
description="\n".join(lines) or "파일 없음",
color=0x3498DB,
)
await channel.send(embed=embed)
# ──────────────────────────────────────
# Calendar
# ──────────────────────────────────────
async def _handle_calendar(self, op: str, params: dict, channel):
if op == "today":
events = await self.calendar.get_today()
embed = discord.Embed(
title=f"📅 오늘 일정 ({datetime.now().strftime('%Y-%m-%d')})",
description="\n".join(e.to_display() for e in events) or "일정 없음 ☀️",
color=0x2ECC71,
)
await channel.send(embed=embed)
elif op == "week":
events = await self.calendar.get_week()
lines = []
current_date = ""
for e in events:
if e.date_str != current_date:
current_date = e.date_str
lines.append(f"\n**📆 {current_date}**")
lines.append(f" {e.to_display()}")
embed = discord.Embed(
title="📅 이번주 일정",
description="\n".join(lines) or "일정 없음 ☀️",
color=0x2ECC71,
)
await channel.send(embed=embed)
elif op == "add":
summary = params.get("summary", "")
date_str = params.get("date", "")
time_str = params.get("time", "09:00")
duration = params.get("duration", "1h")
location = params.get("location", "")
dtstart = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
dur_match = re.match(r"(\d+)([hm])", duration)
if dur_match:
val, unit = int(dur_match.group(1)), dur_match.group(2)
delta = timedelta(hours=val) if unit == "h" else timedelta(minutes=val)
else:
delta = timedelta(hours=1)
dtend = dtstart + delta
event = await self.calendar.create_event(
summary, dtstart, dtend, location=location,
)
embed = discord.Embed(
title="✅ 일정 생성",
description=event.to_display(),
color=0x2ECC71,
)
await channel.send(embed=embed)
elif op == "delete":
uid = params.get("uid", "")
ok = await self.calendar.delete_event(uid)
if ok:
await channel.send(f"✅ 일정 삭제 완료: `{uid}`")
else:
await channel.send(f"❌ 삭제 실패: `{uid}`")
elif op == "list_calendars":
cals = await self.calendar.list_calendars()
embed = discord.Embed(
title="📅 캘린더 목록",
description="\n".join(f"{c}" for c in cals),
color=0x2ECC71,
)
await channel.send(embed=embed)
# ──────────────────────────────────────
# Mail
# ──────────────────────────────────────
async def _handle_mail(self, op: str, params: dict, channel):
if op == "unread":
limit = params.get("limit", 5)
messages = await self.mail.get_unread(limit)
if not messages:
await channel.send("📭 미확인 메일이 없습니다.")
return
lines = []
for i, m in enumerate(messages, 1):
lines.append(
f"{i}. 📬 **{m.sender_name}**\n"
f" 제목: {m.subject}\n"
f" 📅 {m.date}"
)
embed = discord.Embed(
title=f"📬 미확인 메일 {len(messages)}",
description="\n\n".join(lines),
color=0xE67E22,
)
await channel.send(embed=embed)
elif op == "search":
query = params.get("query", "")
messages = await self.mail.search(query)
if not messages:
await channel.send(f"🔍 `{query}` 검색 결과가 없습니다.")
return
lines = [f"{i}. {'📬' if not m.is_read else '📭'} {m.sender_name}{m.subject}"
for i, m in enumerate(messages[:10], 1)]
embed = discord.Embed(
title=f"🔍 메일 검색: {query}",
description="\n".join(lines),
color=0xE67E22,
)
await channel.send(embed=embed)
elif op == "get":
uid = params.get("uid", "")
msg = await self.mail.get_message(uid)
if msg:
body_preview = msg.body[:1500].replace("```", "")
embed = discord.Embed(
title=f"📧 {msg.subject}",
description=f"**From:** {msg.sender}\n**Date:** {msg.date}\n\n{body_preview}",
color=0xE67E22,
)
await channel.send(embed=embed)
else:
await channel.send(f"❌ UID `{uid}` 메일을 찾을 수 없습니다.")
# ──────────────────────────────────────
# Contacts
# ──────────────────────────────────────
async def _handle_contacts(self, op: str, params: dict, channel):
if op == "search":
query = params.get("query", "")
contacts = await self.contacts.search_contacts(query)
if not contacts:
await channel.send(f"🔍 `{query}` 연락처를 찾을 수 없습니다.")
return
lines = [c.to_display() for c in contacts[:10]]
embed = discord.Embed(
title=f"🔍 연락처 검색: {query}",
description="\n\n".join(lines),
color=0x9B59B6,
)
await channel.send(embed=embed)
elif op == "list":
contacts = await self.contacts.list_contacts()
lines = [f"👤 **{c.name}** — {', '.join(c.phone) or '전화 없음'}"
for c in contacts[:20]]
embed = discord.Embed(
title=f"📇 연락처 {len(contacts)}",
description="\n".join(lines) or "연락처 없음",
color=0x9B59B6,
)
await channel.send(embed=embed)
elif op == "scan":
image_url = params.get("image_url", "")
if not image_url:
await channel.send("❌ 스캔할 이미지가 없습니다.")
return
contact = await self.contacts.scan_card(image_url)
embed = discord.Embed(
title="📇 명함 스캔 결과",
description=contact.to_display(),
color=0x9B59B6,
)
await channel.send(embed=embed)