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:
301
handlers/nc_handler.py
Normal file
301
handlers/nc_handler.py
Normal 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)
|
||||
Reference in New Issue
Block a user