"""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}