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
640 lines
18 KiB
Python
640 lines
18 KiB
Python
"""
|
|
Calendar Service - FastAPI application for CalDAV integration
|
|
Provides unified API for Sofiia agent to manage calendars
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime
|
|
|
|
from fastapi import FastAPI, HTTPException, Header, Depends, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
|
|
from calendar_client import CalDAVClient
|
|
from storage import CalendarStorage
|
|
from reminder_worker import ReminderWorker
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Database
|
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./calendar.db")
|
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
Base = declarative_base()
|
|
|
|
|
|
def get_db():
|
|
"""Database session dependency"""
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# MODELS
|
|
# =============================================================================
|
|
|
|
class ConnectRequest(BaseModel):
|
|
workspace_id: str
|
|
user_id: str
|
|
username: str
|
|
password: str
|
|
provider: str = "radicale"
|
|
|
|
|
|
class CreateEventRequest(BaseModel):
|
|
title: str
|
|
start: str # ISO 8601
|
|
end: str # ISO 8601
|
|
timezone: str = "Europe/Kiev"
|
|
location: Optional[str] = None
|
|
description: Optional[str] = None
|
|
attendees: Optional[List[str]] = None
|
|
idempotency_key: Optional[str] = None
|
|
|
|
|
|
class UpdateEventRequest(BaseModel):
|
|
title: Optional[str] = None
|
|
start: Optional[str] = None
|
|
end: Optional[str] = None
|
|
timezone: str = "Europe/Kiev"
|
|
location: Optional[str] = None
|
|
description: Optional[str] = None
|
|
|
|
|
|
class SetReminderRequest(BaseModel):
|
|
event_uid: str
|
|
remind_at: str # ISO 8601
|
|
channel: str = "inapp" # inapp, telegram, email
|
|
|
|
|
|
class CalendarToolRequest(BaseModel):
|
|
action: str
|
|
workspace_id: str
|
|
user_id: str
|
|
provider: str = "radicale"
|
|
account_id: Optional[str] = None
|
|
calendar_id: Optional[str] = None
|
|
params: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
# =============================================================================
|
|
# APP SETUP
|
|
# =============================================================================
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Startup and shutdown events"""
|
|
# Startup
|
|
logger.info("Starting calendar service...")
|
|
|
|
# Create tables
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
# Initialize storage
|
|
storage = CalendarStorage(SessionLocal())
|
|
app.state.storage = storage
|
|
|
|
# Initialize reminder worker
|
|
worker = ReminderWorker(storage)
|
|
app.state.reminder_worker = worker
|
|
worker.start()
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
logger.info("Shutting down calendar service...")
|
|
worker.stop()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Calendar Service",
|
|
description="CalDAV integration for DAARION agents",
|
|
version="1.0.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# AUTH & RBAC
|
|
# =============================================================================
|
|
|
|
async def verify_agent(
|
|
x_agent_id: str = Header(default="anonymous"),
|
|
x_workspace_id: str = Header(default=None)
|
|
) -> Dict[str, str]:
|
|
"""Verify agent authorization"""
|
|
# In production, verify against RBAC system
|
|
return {
|
|
"agent_id": x_agent_id,
|
|
"workspace_id": x_workspace_id or "default"
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# CONNECTION MANAGEMENT
|
|
# =============================================================================
|
|
|
|
@app.post("/v1/calendar/connect/radicale")
|
|
async def connect_radicale(
|
|
request: ConnectRequest,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""Connect a Radicale CalDAV account"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
try:
|
|
# Create CalDAV client and test connection
|
|
client = CalDAVClient(
|
|
server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"),
|
|
username=request.username,
|
|
password=request.password
|
|
)
|
|
|
|
# Test connection
|
|
calendars = client.list_calendars()
|
|
|
|
# Find default calendar
|
|
default_cal = None
|
|
for cal in calendars:
|
|
if cal.get("display_name") in ["default", "Calendar", "Personal"]:
|
|
default_cal = cal.get("id")
|
|
break
|
|
|
|
if not default_cal and calendars:
|
|
default_cal = calendars[0].get("id")
|
|
|
|
# Store account
|
|
account = storage.create_account(
|
|
workspace_id=request.workspace_id,
|
|
user_id=request.user_id,
|
|
provider=request.provider,
|
|
username=request.username,
|
|
password=request.password,
|
|
principal_url=client.principal_url,
|
|
default_calendar_id=default_cal
|
|
)
|
|
|
|
return {
|
|
"status": "connected",
|
|
"account_id": account.id,
|
|
"calendars": calendars,
|
|
"default_calendar": default_cal
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect account: {e}")
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.get("/v1/calendar/accounts")
|
|
async def list_accounts(
|
|
workspace_id: str,
|
|
user_id: str,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""List connected calendar accounts"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
accounts = storage.list_accounts(workspace_id, user_id)
|
|
|
|
return {
|
|
"accounts": [
|
|
{
|
|
"id": a.id,
|
|
"provider": a.provider,
|
|
"username": a.username,
|
|
"default_calendar_id": a.default_calendar_id,
|
|
"created_at": a.created_at.isoformat()
|
|
}
|
|
for a in accounts
|
|
]
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# CALENDAR OPERATIONS
|
|
# =============================================================================
|
|
|
|
@app.get("/v1/calendar/calendars")
|
|
async def list_calendars(
|
|
account_id: str,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""List calendars for an account"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
account = storage.get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
client = CalDAVClient(
|
|
server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"),
|
|
username=account.username,
|
|
password=account.password
|
|
)
|
|
|
|
calendars = client.list_calendars()
|
|
|
|
return {"calendars": calendars}
|
|
|
|
|
|
@app.get("/v1/calendar/events")
|
|
async def list_events(
|
|
account_id: str,
|
|
calendar_id: Optional[str] = None,
|
|
time_min: Optional[str] = None,
|
|
time_max: Optional[str] = None,
|
|
q: Optional[str] = None,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""List events from calendar"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
account = storage.get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
calendar_id = calendar_id or account.default_calendar_id
|
|
|
|
client = CalDAVClient(
|
|
server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"),
|
|
username=account.username,
|
|
password=account.password
|
|
)
|
|
|
|
events = client.list_events(
|
|
calendar_id=calendar_id,
|
|
time_min=time_min,
|
|
time_max=time_max
|
|
)
|
|
|
|
# Filter by query if provided
|
|
if q:
|
|
events = [e for e in events if q.lower() in e.get("title", "").lower()]
|
|
|
|
return {"events": events}
|
|
|
|
|
|
@app.get("/v1/calendar/events/{uid}")
|
|
async def get_event(
|
|
uid: str,
|
|
account_id: str,
|
|
calendar_id: Optional[str] = None,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""Get single event"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
account = storage.get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
calendar_id = calendar_id or account.default_calendar_id
|
|
|
|
client = CalDAVClient(
|
|
server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"),
|
|
username=account.username,
|
|
password=account.password
|
|
)
|
|
|
|
event = client.get_event(uid, calendar_id)
|
|
|
|
if not event:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
return {"event": event}
|
|
|
|
|
|
@app.post("/v1/calendar/events")
|
|
async def create_event(
|
|
request: CreateEventRequest,
|
|
account_id: str,
|
|
calendar_id: Optional[str] = None,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""Create new calendar event"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
# Check idempotency
|
|
if request.idempotency_key:
|
|
existing = storage.get_by_idempotency_key(request.idempotency_key)
|
|
if existing:
|
|
return {
|
|
"status": "already_exists",
|
|
"event_uid": existing.event_uid,
|
|
"message": "Event already exists"
|
|
}
|
|
|
|
account = storage.get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
calendar_id = calendar_id or account.default_calendar_id
|
|
|
|
client = CalDAVClient(
|
|
server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"),
|
|
username=account.username,
|
|
password=account.password
|
|
)
|
|
|
|
event_uid = client.create_event(
|
|
calendar_id=calendar_id,
|
|
title=request.title,
|
|
start=request.start,
|
|
end=request.end,
|
|
timezone=request.timezone,
|
|
location=request.location,
|
|
description=request.description,
|
|
attendees=request.attendees
|
|
)
|
|
|
|
# Store idempotency key
|
|
if request.idempotency_key:
|
|
storage.store_idempotency_key(
|
|
request.idempotency_key,
|
|
account.workspace_id,
|
|
account.user_id,
|
|
event_uid
|
|
)
|
|
|
|
return {
|
|
"status": "created",
|
|
"event_uid": event_uid
|
|
}
|
|
|
|
|
|
@app.patch("/v1/calendar/events/{uid}")
|
|
async def update_event(
|
|
uid: str,
|
|
request: UpdateEventRequest,
|
|
account_id: str,
|
|
calendar_id: Optional[str] = None,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""Update existing event"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
account = storage.get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
calendar_id = calendar_id or account.default_calendar_id
|
|
|
|
client = CalDAVClient(
|
|
server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"),
|
|
username=account.username,
|
|
password=account.password
|
|
)
|
|
|
|
client.update_event(
|
|
uid=uid,
|
|
calendar_id=calendar_id,
|
|
title=request.title,
|
|
start=request.start,
|
|
end=request.end,
|
|
timezone=request.timezone,
|
|
location=request.location,
|
|
description=request.description
|
|
)
|
|
|
|
return {"status": "updated", "event_uid": uid}
|
|
|
|
|
|
@app.delete("/v1/calendar/events/{uid}")
|
|
async def delete_event(
|
|
uid: str,
|
|
account_id: str,
|
|
calendar_id: Optional[str] = None,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""Delete event"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
account = storage.get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
calendar_id = calendar_id or account.default_calendar_id
|
|
|
|
client = CalDAVClient(
|
|
server_url=os.getenv("RADICALE_URL", "https://caldav.daarion.space"),
|
|
username=account.username,
|
|
password=account.password
|
|
)
|
|
|
|
client.delete_event(uid, calendar_id)
|
|
|
|
return {"status": "deleted", "event_uid": uid}
|
|
|
|
|
|
# =============================================================================
|
|
# REMINDERS
|
|
# =============================================================================
|
|
|
|
@app.post("/v1/calendar/reminders")
|
|
async def set_reminder(
|
|
request: SetReminderRequest,
|
|
account_id: str,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""Set event reminder"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
reminder = storage.create_reminder(
|
|
workspace_id=auth["workspace_id"],
|
|
user_id=auth["user_id"],
|
|
account_id=account_id,
|
|
event_uid=request.event_uid,
|
|
remind_at=request.remind_at,
|
|
channel=request.channel
|
|
)
|
|
|
|
return {
|
|
"status": "created",
|
|
"reminder_id": reminder.id
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# CALENDAR TOOL FOR SOFIIA
|
|
# =============================================================================
|
|
|
|
@app.post("/v1/tools/calendar")
|
|
async def calendar_tool(
|
|
request: CalendarToolRequest,
|
|
auth: Dict = Depends(verify_agent),
|
|
db=Depends(get_db)
|
|
):
|
|
"""
|
|
Unified calendar tool endpoint for Sofiia agent.
|
|
|
|
Actions:
|
|
- connect: Connect Radicale account
|
|
- list_calendars: List available calendars
|
|
- list_events: List events in calendar
|
|
- get_event: Get single event
|
|
- create_event: Create new event
|
|
- update_event: Update event
|
|
- delete_event: Delete event
|
|
- set_reminder: Set reminder
|
|
"""
|
|
storage: CalendarStorage = app.state.storage
|
|
|
|
try:
|
|
if request.action == "connect":
|
|
# Connect account
|
|
params = request.params
|
|
connect_req = ConnectRequest(
|
|
workspace_id=request.workspace_id,
|
|
user_id=request.user_id,
|
|
username=params.get("username"),
|
|
password=params.get("password")
|
|
)
|
|
# Reuse connect logic
|
|
result = await connect_radicale(connect_req, auth, db)
|
|
return {"status": "succeeded", "data": result}
|
|
|
|
elif request.action == "list_calendars":
|
|
if not request.account_id:
|
|
raise HTTPException(status_code=400, detail="account_id required")
|
|
return await list_calendars(request.account_id, auth, db)
|
|
|
|
elif request.action == "list_events":
|
|
if not request.account_id:
|
|
raise HTTPException(status_code=400, detail="account_id required")
|
|
params = request.params
|
|
return await list_events(
|
|
request.account_id,
|
|
request.calendar_id,
|
|
params.get("time_min"),
|
|
params.get("time_max"),
|
|
params.get("q"),
|
|
auth,
|
|
db
|
|
)
|
|
|
|
elif request.action == "get_event":
|
|
if not request.account_id:
|
|
raise HTTPException(status_code=400, detail="account_id required")
|
|
params = request.params
|
|
return await get_event(
|
|
params.get("uid"),
|
|
request.account_id,
|
|
request.calendar_id,
|
|
auth,
|
|
db
|
|
)
|
|
|
|
elif request.action == "create_event":
|
|
if not request.account_id:
|
|
raise HTTPException(status_code=400, detail="account_id required")
|
|
params = request.params
|
|
create_req = CreateEventRequest(**params)
|
|
return await create_event(
|
|
create_req,
|
|
request.account_id,
|
|
request.calendar_id,
|
|
auth,
|
|
db
|
|
)
|
|
|
|
elif request.action == "update_event":
|
|
if not request.account_id:
|
|
raise HTTPException(status_code=400, detail="account_id required")
|
|
params = request.params
|
|
update_req = UpdateEventRequest(**params)
|
|
return await update_event(
|
|
params.get("uid"),
|
|
update_req,
|
|
request.account_id,
|
|
request.calendar_id,
|
|
auth,
|
|
db
|
|
)
|
|
|
|
elif request.action == "delete_event":
|
|
if not request.account_id:
|
|
raise HTTPException(status_code=400, detail="account_id required")
|
|
params = request.params
|
|
return await delete_event(
|
|
params.get("uid"),
|
|
request.account_id,
|
|
request.calendar_id,
|
|
auth,
|
|
db
|
|
)
|
|
|
|
elif request.action == "set_reminder":
|
|
if not request.account_id:
|
|
raise HTTPException(status_code=400, detail="account_id required")
|
|
params = request.params
|
|
reminder_req = SetReminderRequest(**params)
|
|
return await set_reminder(reminder_req, request.account_id, auth, db)
|
|
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Unknown action: {request.action}")
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Calendar tool error: {e}")
|
|
return {
|
|
"status": "failed",
|
|
"error": {
|
|
"code": "internal_error",
|
|
"message": str(e),
|
|
"retryable": False
|
|
}
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# HEALTH & METRICS
|
|
# =============================================================================
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
"""Health check"""
|
|
return {"status": "healthy"}
|
|
|
|
|
|
@app.get("/metrics")
|
|
async def metrics():
|
|
"""Service metrics"""
|
|
storage: CalendarStorage = app.state.storage
|
|
worker: ReminderWorker = app.state.reminder_worker
|
|
|
|
return {
|
|
"accounts_count": storage.count_accounts(),
|
|
"reminders_pending": storage.count_pending_reminders(),
|
|
"worker_status": worker.get_status()
|
|
}
|