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

454
tools/nc_calendar.py Normal file
View File

@@ -0,0 +1,454 @@
r"""Nextcloud Calendar (CalDAV) 모듈 — 일정 CRUD.
사용자 명령 기반 일정 조회/추가/수정/삭제.
CalDAV 프로토콜로 iCalendar (VEVENT) 객체를 관리.
"""
import asyncio
import logging
import os
import re
import sys
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import config
from tools.nextcloud_client import NextcloudClient, DAV_NS, CAL_NS
logger = logging.getLogger("variet.tools.nc_calendar")
@dataclass
class CalEvent:
"""캘린더 이벤트."""
uid: str
summary: str
dtstart: datetime
dtend: Optional[datetime] = None
location: str = ""
description: str = ""
href: str = "" # CalDAV 리소스 경로
calendar: str = "" # 소속 캘린더 이름
raw_ics: str = "" # 원본 ICS 데이터
@property
def duration_str(self) -> str:
if self.dtend and self.dtstart:
delta = self.dtend - self.dtstart
hours = delta.seconds // 3600
mins = (delta.seconds % 3600) // 60
if hours > 0:
return f"{hours}시간 {mins}" if mins else f"{hours}시간"
return f"{mins}"
return ""
@property
def date_str(self) -> str:
return self.dtstart.strftime("%Y-%m-%d")
@property
def time_str(self) -> str:
return self.dtstart.strftime("%H:%M")
def to_display(self) -> str:
"""Discord embed용 표시 문자열."""
time = self.time_str
dur = f" ({self.duration_str})" if self.duration_str else ""
loc = f" 📍{self.location}" if self.location else ""
return f"{time}{dur} — **{self.summary}**{loc}"
class NCCalendarClient:
"""Nextcloud CalDAV 캘린더 클라이언트."""
DEFAULT_CALENDAR = "personal"
def __init__(self, nc_client: NextcloudClient = None):
self.nc = nc_client or NextcloudClient()
def _calendar_path(self, calendar: str = None) -> str:
cal = calendar or self.DEFAULT_CALENDAR
return f"calendars/{self.nc.username}/{cal}"
# ──────────────────────────────────────
# 캘린더 목록
# ──────────────────────────────────────
async def list_calendars(self) -> list[str]:
"""사용자 캘린더 목록 조회."""
path = f"calendars/{self.nc.username}/"
resp = await self.nc.dav_request(
"PROPFIND", path,
body="""<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname/>
<d:resourcetype/>
</d:prop>
</d:propfind>""",
headers={"Depth": "1"},
)
calendars = []
if resp.status_code == 207:
from xml.etree import ElementTree as ET
root = ET.fromstring(resp.text)
for r in root.findall(f"{{{DAV_NS}}}response"):
href = r.find(f"{{{DAV_NS}}}href")
propstat = r.find(f"{{{DAV_NS}}}propstat")
if propstat is None:
continue
prop = propstat.find(f"{{{DAV_NS}}}prop")
if prop is None:
continue
rtype = prop.find(f"{{{DAV_NS}}}resourcetype")
if rtype is not None and rtype.find(f"{{{CAL_NS}}}calendar") is not None:
dn = prop.find(f"{{{DAV_NS}}}displayname")
name = dn.text if dn is not None and dn.text else href.text.rstrip("/").split("/")[-1]
calendars.append(name)
return calendars
# ──────────────────────────────────────
# 이벤트 조회
# ──────────────────────────────────────
async def get_events(
self,
start: datetime = None,
end: datetime = None,
calendar: str = None,
) -> list[CalEvent]:
"""기간 내 이벤트 조회.
Args:
start: 시작일 (기본: 오늘 00:00)
end: 종료일 (기본: start + 7일)
calendar: 캘린더 이름 (기본: personal)
"""
if start is None:
start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
if end is None:
end = start + timedelta(days=7)
start_str = start.strftime("%Y%m%dT%H%M%SZ")
end_str = end.strftime("%Y%m%dT%H%M%SZ")
body = f"""<?xml version="1.0" encoding="UTF-8"?>
<cal:calendar-query xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<cal:calendar-data/>
</d:prop>
<cal:filter>
<cal:comp-filter name="VCALENDAR">
<cal:comp-filter name="VEVENT">
<cal:time-range start="{start_str}" end="{end_str}"/>
</cal:comp-filter>
</cal:comp-filter>
</cal:filter>
</cal:calendar-query>"""
path = self._calendar_path(calendar)
resp = await self.nc.dav_request(
"REPORT", path, body=body,
headers={"Depth": "1"},
)
events = []
if resp.status_code == 207:
from xml.etree import ElementTree as ET
root = ET.fromstring(resp.text)
for r in root.findall(f"{{{DAV_NS}}}response"):
href_el = r.find(f"{{{DAV_NS}}}href")
href = href_el.text if href_el is not None else ""
propstat = r.find(f"{{{DAV_NS}}}propstat")
if propstat is None:
continue
prop = propstat.find(f"{{{DAV_NS}}}prop")
if prop is None:
continue
cal_data = prop.find(f"{{{CAL_NS}}}calendar-data")
if cal_data is not None and cal_data.text:
event = self._parse_ics(cal_data.text, href, calendar or self.DEFAULT_CALENDAR)
if event:
events.append(event)
events.sort(key=lambda e: e.dtstart)
logger.info(f"캘린더 이벤트 조회: {len(events)}건 ({start.date()} ~ {end.date()})")
return events
async def get_today(self) -> list[CalEvent]:
"""오늘 일정 조회."""
now = datetime.now()
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1)
return await self.get_events(start, end)
async def get_week(self) -> list[CalEvent]:
"""이번주 일정 조회."""
now = datetime.now()
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# 이번주 월요일부터
start = start - timedelta(days=start.weekday())
end = start + timedelta(days=7)
return await self.get_events(start, end)
# ──────────────────────────────────────
# 이벤트 생성
# ──────────────────────────────────────
async def create_event(
self,
summary: str,
dtstart: datetime,
dtend: datetime = None,
location: str = "",
description: str = "",
calendar: str = None,
) -> CalEvent:
"""이벤트 생성.
Args:
summary: 제목
dtstart: 시작 시간
dtend: 종료 시간 (기본: 시작 + 1시간)
location: 장소
description: 설명
calendar: 캘린더 이름
"""
uid = f"{uuid.uuid4()}@variet-agent"
if dtend is None:
dtend = dtstart + timedelta(hours=1)
ics = self._build_ics(uid, summary, dtstart, dtend, location, description)
path = f"{self._calendar_path(calendar)}/{uid}.ics"
resp = await self.nc.dav_put(path, ics, "text/calendar; charset=utf-8")
if resp.status_code in (200, 201, 204):
logger.info(f"일정 생성: {summary} ({dtstart})")
return CalEvent(
uid=uid, summary=summary, dtstart=dtstart, dtend=dtend,
location=location, description=description, href=path,
calendar=calendar or self.DEFAULT_CALENDAR, raw_ics=ics,
)
else:
raise RuntimeError(f"일정 생성 실패: {resp.status_code} {resp.text[:200]}")
# ──────────────────────────────────────
# 이벤트 수정
# ──────────────────────────────────────
async def update_event(
self,
uid: str,
calendar: str = None,
summary: str = None,
dtstart: datetime = None,
dtend: datetime = None,
location: str = None,
description: str = None,
) -> CalEvent:
"""이벤트 수정 — 기존 이벤트를 찾아서 변경."""
# 먼저 기존 이벤트를 찾음
events = await self.get_events(
start=datetime(2020, 1, 1),
end=datetime(2030, 12, 31),
calendar=calendar,
)
target = None
for e in events:
if e.uid == uid:
target = e
break
if target is None:
raise RuntimeError(f"이벤트 {uid}를 찾을 수 없습니다.")
# 변경 적용
new_summary = summary if summary is not None else target.summary
new_dtstart = dtstart if dtstart is not None else target.dtstart
new_dtend = dtend if dtend is not None else target.dtend
new_location = location if location is not None else target.location
new_description = description if description is not None else target.description
ics = self._build_ics(uid, new_summary, new_dtstart, new_dtend, new_location, new_description)
path = target.href or f"{self._calendar_path(calendar)}/{uid}.ics"
resp = await self.nc.dav_put(path, ics, "text/calendar; charset=utf-8")
if resp.status_code in (200, 201, 204):
logger.info(f"일정 수정: {new_summary}")
return CalEvent(
uid=uid, summary=new_summary, dtstart=new_dtstart, dtend=new_dtend,
location=new_location, description=new_description, href=path,
calendar=calendar or self.DEFAULT_CALENDAR,
)
else:
raise RuntimeError(f"일정 수정 실패: {resp.status_code}")
# ──────────────────────────────────────
# 이벤트 삭제
# ──────────────────────────────────────
async def delete_event(self, uid: str, calendar: str = None) -> bool:
"""이벤트 삭제."""
# href 직접 삭제 시도
path = f"{self._calendar_path(calendar)}/{uid}.ics"
ok = await self.nc.dav_delete(path)
if ok:
logger.info(f"일정 삭제: {uid}")
else:
logger.warning(f"일정 삭제 실패: {uid}")
return ok
# ──────────────────────────────────────
# ICS 빌드/파싱
# ──────────────────────────────────────
@staticmethod
def _build_ics(
uid: str, summary: str,
dtstart: datetime, dtend: datetime,
location: str = "", description: str = "",
) -> str:
"""VEVENT ICS 문자열 생성."""
now = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
ds = dtstart.strftime("%Y%m%dT%H%M%S")
de = dtend.strftime("%Y%m%dT%H%M%S")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Variet-Agent//NC Calendar//KO",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{now}",
f"DTSTART:{ds}",
f"DTEND:{de}",
f"SUMMARY:{summary}",
]
if location:
lines.append(f"LOCATION:{location}")
if description:
lines.append(f"DESCRIPTION:{description}")
lines.extend([
"END:VEVENT",
"END:VCALENDAR",
])
return "\r\n".join(lines)
@staticmethod
def _parse_ics(ics_text: str, href: str = "", calendar: str = "") -> Optional[CalEvent]:
"""ICS 텍스트에서 CalEvent 추출 (간단 파서)."""
def _get_field(text: str, field: str) -> str:
pattern = rf"^{field}[;:](.+)$"
m = re.search(pattern, text, re.MULTILINE)
return m.group(1).strip() if m else ""
def _parse_dt(val: str) -> Optional[datetime]:
if not val:
return None
# VALUE=DATE:20260318 형식 처리
if "VALUE=DATE:" in val:
val = val.split(":")[-1]
elif ":" in val:
val = val.split(":")[-1]
val = val.replace("Z", "")
for fmt in ("%Y%m%dT%H%M%S", "%Y%m%d"):
try:
return datetime.strptime(val, fmt)
except ValueError:
continue
return None
uid = _get_field(ics_text, "UID")
summary = _get_field(ics_text, "SUMMARY")
dtstart = _parse_dt(_get_field(ics_text, "DTSTART"))
dtend = _parse_dt(_get_field(ics_text, "DTEND"))
location = _get_field(ics_text, "LOCATION")
description = _get_field(ics_text, "DESCRIPTION")
if not uid or not dtstart:
return None
return CalEvent(
uid=uid, summary=summary, dtstart=dtstart, dtend=dtend,
location=location, description=description,
href=href, calendar=calendar, raw_ics=ics_text,
)
# ──────────────────────────────────────
# CLI
# ──────────────────────────────────────
async def _cli():
import io
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
args = sys.argv[1:]
client = NCCalendarClient()
if not args:
print("사용법:")
print(" nc_calendar.py calendars")
print(" nc_calendar.py today")
print(" nc_calendar.py week")
print(' nc_calendar.py add "제목" 2026-03-20 14:00 [1h]')
print(' nc_calendar.py delete <uid>')
return
if args[0] == "calendars":
cals = await client.list_calendars()
print(f"📅 캘린더 {len(cals)}개:")
for c in cals:
print(f"{c}")
elif args[0] == "today":
events = await client.get_today()
print(f"📅 오늘 일정 ({datetime.now().strftime('%Y-%m-%d')}):\n")
if not events:
print(" 일정 없음")
for e in events:
print(f" {e.to_display()}")
elif args[0] == "week":
events = await client.get_week()
print(f"📅 이번주 일정:\n")
current_date = ""
for e in events:
if e.date_str != current_date:
current_date = e.date_str
print(f"\n 📆 {current_date}")
print(f" {e.to_display()}")
elif args[0] == "add" and len(args) >= 3:
summary = args[1]
date_str = args[2]
time_str = args[3] if len(args) > 3 else "09:00"
duration = args[4] if len(args) > 4 else "1h"
dtstart = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
# duration 파싱
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 client.create_event(summary, dtstart, dtend)
print(f"✅ 일정 생성: {event.to_display()}")
print(f" UID: {event.uid}")
elif args[0] == "delete" and len(args) > 1:
uid = args[1]
ok = await client.delete_event(uid)
print(f"{'✅ 삭제 완료' if ok else '❌ 삭제 실패'}: {uid}")
if __name__ == "__main__":
asyncio.run(_cli())