- 파이프라인 42→51노드 확장 (calendar/mail/note 핸들러 추가) - 네이티브 서비스 6개: heic_converter(:8090), chat_bridge(:8091), caldav_bridge(:8092), devonthink_bridge(:8093), inbox_processor, news_digest - 분류기 v2→v3: calendar, reminder, mail, note intent 추가 - Mail Processing Pipeline (7노드, IMAP 폴링) - LaunchAgent plist 6개 + manage_services.sh - migrate-v3.sql: news_digest_log + calendar_events 확장 - 개발 문서 현행화 (CLAUDE.md, QUICK_REFERENCE.md, docs/architecture.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
270 lines
9.6 KiB
Python
270 lines
9.6 KiB
Python
"""CalDAV Bridge — Synology Calendar REST API 래퍼 (port 8092)"""
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
from dotenv import load_dotenv
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
from icalendar import Calendar, Event, vText
|
|
|
|
load_dotenv()
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
logger = logging.getLogger("caldav_bridge")
|
|
|
|
CALDAV_BASE_URL = os.getenv("CALDAV_BASE_URL", "https://192.168.1.227:5001/caldav")
|
|
CALDAV_USER = os.getenv("CALDAV_USER", "chatbot-api")
|
|
CALDAV_PASSWORD = os.getenv("CALDAV_PASSWORD", "")
|
|
CALDAV_CALENDAR = os.getenv("CALDAV_CALENDAR", "chatbot")
|
|
KST = ZoneInfo("Asia/Seoul")
|
|
|
|
CALENDAR_URL = f"{CALDAV_BASE_URL}/{CALDAV_USER}/{CALDAV_CALENDAR}/"
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
def _client() -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(
|
|
verify=False,
|
|
auth=(CALDAV_USER, CALDAV_PASSWORD),
|
|
timeout=15,
|
|
)
|
|
|
|
|
|
def _make_ical(title: str, start: str, end: str | None, location: str | None,
|
|
description: str | None, uid: str) -> bytes:
|
|
"""iCalendar 이벤트 생성."""
|
|
cal = Calendar()
|
|
cal.add("prodid", "-//SynChatBot//CalDAV Bridge//KO")
|
|
cal.add("version", "2.0")
|
|
|
|
evt = Event()
|
|
evt.add("uid", uid)
|
|
evt.add("dtstamp", datetime.now(KST))
|
|
evt.add("summary", title)
|
|
|
|
dt_start = datetime.fromisoformat(start)
|
|
if dt_start.tzinfo is None:
|
|
dt_start = dt_start.replace(tzinfo=KST)
|
|
evt.add("dtstart", dt_start)
|
|
|
|
if end:
|
|
dt_end = datetime.fromisoformat(end)
|
|
if dt_end.tzinfo is None:
|
|
dt_end = dt_end.replace(tzinfo=KST)
|
|
evt.add("dtend", dt_end)
|
|
else:
|
|
evt.add("dtend", dt_start + timedelta(hours=1))
|
|
|
|
if location:
|
|
evt["location"] = vText(location)
|
|
if description:
|
|
evt["description"] = vText(description)
|
|
|
|
cal.add_component(evt)
|
|
return cal.to_ical()
|
|
|
|
|
|
def _parse_events(ical_data: bytes) -> list[dict]:
|
|
"""iCalendar 데이터에서 이벤트 목록 추출."""
|
|
events = []
|
|
try:
|
|
cals = Calendar.from_ical(ical_data, multiple=True) if hasattr(Calendar, 'from_ical') else [Calendar.from_ical(ical_data)]
|
|
except Exception:
|
|
cals = [Calendar.from_ical(ical_data)]
|
|
for cal in cals:
|
|
for component in cal.walk():
|
|
if component.name == "VEVENT":
|
|
dt_start = component.get("dtstart")
|
|
dt_end = component.get("dtend")
|
|
events.append({
|
|
"uid": str(component.get("uid", "")),
|
|
"title": str(component.get("summary", "")),
|
|
"start": dt_start.dt.isoformat() if dt_start else None,
|
|
"end": dt_end.dt.isoformat() if dt_end else None,
|
|
"location": str(component.get("location", "")),
|
|
"description": str(component.get("description", "")),
|
|
})
|
|
return events
|
|
|
|
|
|
@app.post("/calendar/create")
|
|
async def create_event(request: Request):
|
|
body = await request.json()
|
|
title = body.get("title", "")
|
|
start = body.get("start", "")
|
|
end = body.get("end")
|
|
location = body.get("location")
|
|
description = body.get("description")
|
|
|
|
if not title or not start:
|
|
return JSONResponse({"success": False, "error": "title and start required"}, status_code=400)
|
|
|
|
uid = f"{uuid.uuid4()}@syn-chat-bot"
|
|
ical = _make_ical(title, start, end, location, description, uid)
|
|
|
|
async with _client() as client:
|
|
resp = await client.put(
|
|
f"{CALENDAR_URL}{uid}.ics",
|
|
content=ical,
|
|
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
|
)
|
|
if resp.status_code in (200, 201, 204):
|
|
logger.info(f"Event created: {uid} '{title}'")
|
|
return JSONResponse({"success": True, "uid": uid})
|
|
logger.error(f"CalDAV PUT failed: {resp.status_code} {resp.text[:200]}")
|
|
return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}"}, status_code=502)
|
|
|
|
|
|
@app.post("/calendar/query")
|
|
async def query_events(request: Request):
|
|
body = await request.json()
|
|
start = body.get("start", "")
|
|
end = body.get("end", "")
|
|
|
|
if not start or not end:
|
|
return JSONResponse({"success": False, "error": "start and end required"}, status_code=400)
|
|
|
|
dt_start = datetime.fromisoformat(start)
|
|
dt_end = datetime.fromisoformat(end)
|
|
if dt_start.tzinfo is None:
|
|
dt_start = dt_start.replace(tzinfo=KST)
|
|
if dt_end.tzinfo is None:
|
|
dt_end = dt_end.replace(tzinfo=KST)
|
|
|
|
xml_body = f"""<?xml version="1.0" encoding="utf-8" ?>
|
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:prop>
|
|
<D:getetag/>
|
|
<C:calendar-data/>
|
|
</D:prop>
|
|
<C:filter>
|
|
<C:comp-filter name="VCALENDAR">
|
|
<C:comp-filter name="VEVENT">
|
|
<C:time-range start="{dt_start.strftime('%Y%m%dT%H%M%SZ')}"
|
|
end="{dt_end.strftime('%Y%m%dT%H%M%SZ')}"/>
|
|
</C:comp-filter>
|
|
</C:comp-filter>
|
|
</C:filter>
|
|
</C:calendar-query>"""
|
|
|
|
async with _client() as client:
|
|
resp = await client.request(
|
|
"REPORT",
|
|
CALENDAR_URL,
|
|
content=xml_body.encode("utf-8"),
|
|
headers={
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"Depth": "1",
|
|
},
|
|
)
|
|
if resp.status_code not in (200, 207):
|
|
logger.error(f"CalDAV REPORT failed: {resp.status_code}")
|
|
return JSONResponse({"success": False, "error": f"CalDAV REPORT {resp.status_code}"}, status_code=502)
|
|
|
|
# Parse multistatus XML for calendar-data
|
|
events = []
|
|
import xml.etree.ElementTree as ET
|
|
try:
|
|
root = ET.fromstring(resp.text)
|
|
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
|
|
for response_elem in root.findall(".//D:response", ns):
|
|
cal_data_elem = response_elem.find(".//C:calendar-data", ns)
|
|
if cal_data_elem is not None and cal_data_elem.text:
|
|
events.extend(_parse_events(cal_data_elem.text.encode("utf-8")))
|
|
except ET.ParseError as e:
|
|
logger.error(f"XML parse error: {e}")
|
|
|
|
return JSONResponse({"success": True, "events": events})
|
|
|
|
|
|
@app.post("/calendar/update")
|
|
async def update_event(request: Request):
|
|
body = await request.json()
|
|
uid = body.get("uid", "")
|
|
if not uid:
|
|
return JSONResponse({"success": False, "error": "uid required"}, status_code=400)
|
|
|
|
ics_url = f"{CALENDAR_URL}{uid}.ics"
|
|
|
|
async with _client() as client:
|
|
# Fetch existing event
|
|
resp = await client.get(ics_url)
|
|
if resp.status_code != 200:
|
|
return JSONResponse({"success": False, "error": f"Event not found: {resp.status_code}"}, status_code=404)
|
|
|
|
# Parse and modify
|
|
try:
|
|
cal = Calendar.from_ical(resp.content)
|
|
except Exception as e:
|
|
return JSONResponse({"success": False, "error": f"Parse error: {e}"}, status_code=500)
|
|
|
|
for component in cal.walk():
|
|
if component.name == "VEVENT":
|
|
if "title" in body and body["title"]:
|
|
component["summary"] = vText(body["title"])
|
|
if "start" in body and body["start"]:
|
|
dt = datetime.fromisoformat(body["start"])
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=KST)
|
|
component["dtstart"].dt = dt
|
|
if "end" in body and body["end"]:
|
|
dt = datetime.fromisoformat(body["end"])
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=KST)
|
|
component["dtend"].dt = dt
|
|
if "location" in body:
|
|
component["location"] = vText(body["location"]) if body["location"] else ""
|
|
if "description" in body:
|
|
component["description"] = vText(body["description"]) if body["description"] else ""
|
|
break
|
|
|
|
# PUT back
|
|
resp = await client.put(
|
|
ics_url,
|
|
content=cal.to_ical(),
|
|
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
|
)
|
|
if resp.status_code in (200, 201, 204):
|
|
logger.info(f"Event updated: {uid}")
|
|
return JSONResponse({"success": True, "uid": uid})
|
|
return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}"}, status_code=502)
|
|
|
|
|
|
@app.post("/calendar/delete")
|
|
async def delete_event(request: Request):
|
|
body = await request.json()
|
|
uid = body.get("uid", "")
|
|
if not uid:
|
|
return JSONResponse({"success": False, "error": "uid required"}, status_code=400)
|
|
|
|
async with _client() as client:
|
|
resp = await client.delete(f"{CALENDAR_URL}{uid}.ics")
|
|
if resp.status_code in (200, 204):
|
|
logger.info(f"Event deleted: {uid}")
|
|
return JSONResponse({"success": True})
|
|
return JSONResponse({"success": False, "error": f"CalDAV DELETE {resp.status_code}"}, status_code=502)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
caldav_reachable = False
|
|
try:
|
|
async with _client() as client:
|
|
resp = await client.request(
|
|
"PROPFIND",
|
|
CALENDAR_URL,
|
|
headers={"Depth": "0", "Content-Type": "application/xml"},
|
|
content=b'<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:prop><D:displayname/></D:prop></D:propfind>',
|
|
)
|
|
caldav_reachable = resp.status_code in (200, 207)
|
|
except Exception as e:
|
|
logger.warning(f"CalDAV health check failed: {e}")
|
|
|
|
return {"status": "ok", "caldav_reachable": caldav_reachable}
|