"""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, Todo, 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")
cal.add("calscale", "GREGORIAN")
# VTIMEZONE for Asia/Seoul (required by Synology Calendar)
from icalendar import Timezone, TimezoneStandard
tz = Timezone()
tz.add("tzid", "Asia/Seoul")
tz_std = TimezoneStandard()
tz_std.add("tzname", "KST")
tz_std.add("tzoffsetfrom", timedelta(hours=9))
tz_std.add("tzoffsetto", timedelta(hours=9))
tz_std.add("dtstart", datetime(1970, 1, 1))
tz.add_component(tz_std)
cal.add_component(tz)
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
def _make_vtodo(title: str, due: str | None, description: str | None, uid: str) -> bytes:
"""iCalendar VTODO 생성."""
cal = Calendar()
cal.add("prodid", "-//SynChatBot//CalDAV Bridge//KO")
cal.add("version", "2.0")
cal.add("calscale", "GREGORIAN")
from icalendar import Timezone, TimezoneStandard
tz = Timezone()
tz.add("tzid", "Asia/Seoul")
tz_std = TimezoneStandard()
tz_std.add("tzname", "KST")
tz_std.add("tzoffsetfrom", timedelta(hours=9))
tz_std.add("tzoffsetto", timedelta(hours=9))
tz_std.add("dtstart", datetime(1970, 1, 1))
tz.add_component(tz_std)
cal.add_component(tz)
todo = Todo()
todo.add("uid", uid)
todo.add("dtstamp", datetime.now(KST))
todo.add("summary", title)
todo.add("created", datetime.now(KST))
todo.add("status", "NEEDS-ACTION")
if due:
dt_due = datetime.fromisoformat(due)
if dt_due.tzinfo is None:
dt_due = dt_due.replace(tzinfo=KST)
todo.add("due", dt_due)
if description:
todo["description"] = vText(description)
cal.add_component(todo)
return cal.to_ical()
@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)
# 표시용 시간 포맷
dt = datetime.fromisoformat(start)
display_time = dt.strftime("%-m/%d %H:%M")
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,
"message": f"일정 등록 완료: {display_time} {title}"})
logger.error(f"CalDAV PUT failed: {resp.status_code} {resp.text[:200]}")
return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}",
"message": f"일정 등록 실패: {title}"}, status_code=502)
@app.post("/calendar/create-todo")
async def create_todo(request: Request):
"""VTODO 생성. body: {title, due?, description?}"""
body = await request.json()
title = body.get("title", "")
due = body.get("due")
description = body.get("description")
if not title:
return JSONResponse({"success": False, "error": "title required"}, status_code=400)
uid = f"{uuid.uuid4()}@syn-chat-bot"
ical = _make_vtodo(title, due, description, uid)
# 표시용 기한
display_due = ""
if due:
dt = datetime.fromisoformat(due)
display_due = f"~{dt.strftime('%-m/%d')} "
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"Todo created: {uid} '{title}'")
return JSONResponse({"success": True, "uid": uid,
"message": f"작업 등록 완료: {display_due}{title}"})
logger.error(f"CalDAV PUT (todo) failed: {resp.status_code} {resp.text[:200]}")
return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}",
"message": f"작업 등록 실패: {title}"}, 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"""
"""
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'',
)
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}