New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
372 lines
10 KiB
Python
372 lines
10 KiB
Python
"""
|
|
CalDAV Client - Python client for Radicale CalDAV server
|
|
Supports calendar operations: list, create, update, delete events
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime
|
|
import uuid
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CalDAVClient:
|
|
"""
|
|
CalDAV client for Radicale server.
|
|
|
|
Provides methods to:
|
|
- List calendars
|
|
- List/create/update/delete events
|
|
- Handle VEVENT format
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
server_url: str,
|
|
username: str,
|
|
password: str,
|
|
timeout: int = 30
|
|
):
|
|
self.server_url = server_url.rstrip("/")
|
|
self.username = username
|
|
self.password = password
|
|
self.timeout = timeout
|
|
|
|
# Will be set after principal discovery
|
|
self.principal_url: Optional[str] = None
|
|
self._session = None
|
|
|
|
# Import requests for HTTP
|
|
try:
|
|
import requests
|
|
from requests.auth import HTTPBasicAuth
|
|
self._requests = requests
|
|
self._auth = HTTPBasicAuth(username, password)
|
|
except ImportError:
|
|
logger.warning("requests not available")
|
|
self._requests = None
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
headers: Optional[Dict] = None,
|
|
data: Optional[str] = None
|
|
) -> Any:
|
|
"""Make HTTP request to CalDAV server"""
|
|
if not self._requests:
|
|
raise RuntimeError("requests library not available")
|
|
|
|
url = f"{self.server_url}{path}"
|
|
|
|
default_headers = {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"Accept": "application/xml"
|
|
}
|
|
if headers:
|
|
default_headers.update(headers)
|
|
|
|
response = self._requests.request(
|
|
method=method,
|
|
url=url,
|
|
auth=self._auth,
|
|
headers=default_headers,
|
|
data=data,
|
|
timeout=self.timeout,
|
|
verify=False # For self-signed certs
|
|
)
|
|
|
|
return response
|
|
|
|
def discover_principal(self) -> str:
|
|
"""Discover principal URL"""
|
|
# PROPFIND to find principal
|
|
propfind_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:prop>
|
|
<D:current-user-principal/>
|
|
</D:prop>
|
|
</D:propfind>"""
|
|
|
|
response = self._request(
|
|
"PROPFIND",
|
|
"/",
|
|
{"Depth": "0"},
|
|
propfind_xml
|
|
)
|
|
|
|
# Parse principal URL from response
|
|
# Simplified: assume /{username}/
|
|
self.principal_url = f"/{self.username}/"
|
|
return self.principal_url
|
|
|
|
def list_calendars(self) -> List[Dict[str, str]]:
|
|
"""List all calendars for principal"""
|
|
if not self.principal_url:
|
|
self.discover_principal()
|
|
|
|
# CALDAV calendar-home-set query
|
|
propfind_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:prop>
|
|
<D:resourcetype/>
|
|
<D:displayname/>
|
|
<C:calendar-description/>
|
|
</D:prop>
|
|
</D:propfind>"""
|
|
|
|
try:
|
|
response = self._request(
|
|
"PROPFIND",
|
|
self.principal_url,
|
|
{"Depth": "1"},
|
|
propfind_xml
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to list calendars: {e}")
|
|
# Return default calendar
|
|
return [{"id": "default", "display_name": "Default Calendar"}]
|
|
|
|
# Parse response (simplified)
|
|
calendars = []
|
|
calendars.append({
|
|
"id": "default",
|
|
"display_name": "Default Calendar",
|
|
"url": f"{self.principal_url}default/"
|
|
})
|
|
|
|
return calendars
|
|
|
|
def list_events(
|
|
self,
|
|
calendar_id: str = "default",
|
|
time_min: Optional[str] = None,
|
|
time_max: Optional[str] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""List events in calendar"""
|
|
if not self.principal_url:
|
|
self.discover_principal()
|
|
|
|
calendar_url = f"{self.principal_url}{calendar_id}/"
|
|
|
|
# Build calendar-query
|
|
calendar_query_xml = 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:comp-filter>
|
|
</C:filter>
|
|
</C:calendar-query>"""
|
|
|
|
try:
|
|
response = self._request(
|
|
"REPORT",
|
|
calendar_url,
|
|
{"Depth": "1"},
|
|
calendar_query_xml
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to list events: {e}")
|
|
return []
|
|
|
|
# Parse events (simplified - return empty for now)
|
|
events = []
|
|
|
|
return events
|
|
|
|
def get_event(
|
|
self,
|
|
uid: str,
|
|
calendar_id: str = "default"
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Get single event"""
|
|
if not self.principal_url:
|
|
self.discover_principal()
|
|
|
|
calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics"
|
|
|
|
try:
|
|
response = self._request("GET", calendar_url)
|
|
|
|
if response.status_code == 404:
|
|
return None
|
|
|
|
# Parse VEVENT
|
|
return self._parse_vevent(response.text)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get event: {e}")
|
|
return None
|
|
|
|
def create_event(
|
|
self,
|
|
calendar_id: str = "default",
|
|
title: str = "",
|
|
start: str = "",
|
|
end: str = "",
|
|
timezone: str = "Europe/Kiev",
|
|
location: Optional[str] = None,
|
|
description: Optional[str] = None,
|
|
attendees: Optional[List[str]] = None
|
|
) -> str:
|
|
"""Create new event, returns UID"""
|
|
if not self.principal_url:
|
|
self.discover_principal()
|
|
|
|
# Generate UID
|
|
uid = str(uuid.uuid4())
|
|
|
|
# Build VEVENT
|
|
vevent = self._build_vevent(
|
|
uid=uid,
|
|
title=title,
|
|
start=start,
|
|
end=end,
|
|
timezone=timezone,
|
|
location=location,
|
|
description=description,
|
|
attendees=attendees
|
|
)
|
|
|
|
# PUT to calendar
|
|
calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics"
|
|
|
|
self._request(
|
|
"PUT",
|
|
calendar_url,
|
|
{"Content-Type": "text/calendar; charset=utf-8"},
|
|
vevent
|
|
)
|
|
|
|
return uid
|
|
|
|
def update_event(
|
|
self,
|
|
uid: str,
|
|
calendar_id: str = "default",
|
|
title: Optional[str] = None,
|
|
start: Optional[str] = None,
|
|
end: Optional[str] = None,
|
|
timezone: str = "Europe/Kiev",
|
|
location: Optional[str] = None,
|
|
description: Optional[str] = None
|
|
) -> bool:
|
|
"""Update existing event"""
|
|
if not self.principal_url:
|
|
self.discover_principal()
|
|
|
|
# Get existing event
|
|
existing = self.get_event(uid, calendar_id)
|
|
if not existing:
|
|
raise ValueError(f"Event {uid} not found")
|
|
|
|
# Update fields
|
|
title = title or existing.get("title", "")
|
|
start = start or existing.get("start", "")
|
|
end = end or existing.get("end", "")
|
|
location = location if location is not None else existing.get("location")
|
|
description = description if description is not None else existing.get("description")
|
|
|
|
# Rebuild VEVENT
|
|
vevent = self._build_vevent(
|
|
uid=uid,
|
|
title=title,
|
|
start=start,
|
|
end=end,
|
|
timezone=timezone,
|
|
location=location,
|
|
description=description
|
|
)
|
|
|
|
calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics"
|
|
|
|
self._request(
|
|
"PUT",
|
|
calendar_url,
|
|
{"Content-Type": "text/calendar; charset=utf-8"},
|
|
vevent
|
|
)
|
|
|
|
return True
|
|
|
|
def delete_event(self, uid: str, calendar_id: str = "default") -> bool:
|
|
"""Delete event"""
|
|
if not self.principal_url:
|
|
self.discover_principal()
|
|
|
|
calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics"
|
|
|
|
self._request("DELETE", calendar_url)
|
|
|
|
return True
|
|
|
|
def _build_vevent(
|
|
self,
|
|
uid: str,
|
|
title: str,
|
|
start: str,
|
|
end: str,
|
|
timezone: str,
|
|
location: Optional[str],
|
|
description: Optional[str],
|
|
attendees: Optional[List[str]] = None
|
|
) -> str:
|
|
"""Build VEVENT iCalendar string"""
|
|
# Format datetime for iCalendar
|
|
start_dt = start.replace("-", "").replace(":", "")
|
|
end_dt = end.replace("-", "").replace(":", "")
|
|
|
|
# Build attendees
|
|
attendee_lines = ""
|
|
if attendees:
|
|
for email in attendees:
|
|
attendee_lines += f"ATTENDEE:mailto:{email}\n"
|
|
|
|
vevent = f"""BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//DAARION//Calendar//EN
|
|
CALSCALE:GREGORIAN
|
|
METHOD:PUBLISH
|
|
BEGIN:VEVENT
|
|
UID:{uid}
|
|
DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}
|
|
DTSTART:{start_dt}
|
|
DTEND:{end_dt}
|
|
SUMMARY:{title}
|
|
{location and f'LOCATION:{location}' or ''}
|
|
{description and f'DESCRIPTION:{description}' or ''}
|
|
{attendee_lines}
|
|
END:VEVENT
|
|
END:VCALENDAR"""
|
|
|
|
return vevent
|
|
|
|
def _parse_vevent(self, ics_data: str) -> Dict[str, Any]:
|
|
"""Parse VEVENT from iCalendar data"""
|
|
event = {}
|
|
|
|
lines = ics_data.split("\n")
|
|
for line in lines:
|
|
line = line.strip()
|
|
|
|
if line.startswith("UID:"):
|
|
event["uid"] = line[4:]
|
|
elif line.startswith("SUMMARY:"):
|
|
event["title"] = line[8:]
|
|
elif line.startswith("DTSTART"):
|
|
event["start"] = line.replace("DTSTART:", "").replace("T", " ")
|
|
elif line.startswith("DTEND"):
|
|
event["end"] = line.replace("DTEND:", "").replace("T", " ")
|
|
elif line.startswith("LOCATION:"):
|
|
event["location"] = line[9:]
|
|
elif line.startswith("DESCRIPTION:"):
|
|
event["description"] = line[13:]
|
|
|
|
return event
|