feat(platform): add new services, tools, tests and crews modules

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
This commit is contained in:
Apple
2026-03-03 07:14:14 -08:00
parent e9dedffa48
commit 129e4ea1fc
241 changed files with 69349 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
"""
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

View File

@@ -0,0 +1,154 @@
# Calendar Sovereignty - Self-Hosted Calendar Infrastructure
## Philosophy
DAARION follows the principle of **digital sovereignty** - owning and controlling our communication infrastructure. Calendar is no exception.
## Current Stack
### Radicale + Caddy (Self-Hosted)
```
┌─────────────────────────────────────────────────────────┐
│ DAARION Network │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Caddy │──────│ Radicale │ │
│ │ (TLS/Proxy) │ │ (CalDAV) │ │
│ └─────────────┘ └─────────────┘ │
│ │ │ │
│ │ ┌──────┴──────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
│ │ iOS │ │ Android │ │ Sofiia │ │
│ │ Calendar│ │ Calendar│ │ Agent │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
### Why Self-Hosted?
1. **Data Ownership** - Your calendar data stays on your servers
2. **No Vendor Lock-in** - Not dependent on Google/Apple/Microsoft
3. **Privacy** - No third parties reading your schedule
4. **Cost** - Free open-source software
5. **Control** - Full control over access, backups, retention
## Radicale Configuration
### Features
- CalDAV protocol support (RFC 4791)
- CardDAV for contacts (optional)
- HTTP Basic Auth
- Server-side encryption (optional)
- Web interface for users
### Endpoints
- Base URL: `https://caldav.daarion.space`
- Web Interface: `http://localhost:5232` (local only)
### User Management
Users are created automatically on first login. No admin panel needed.
```bash
# Access Radicale container
docker exec -it daarion-radicale /bin/sh
# View logs
docker logs daarion-radicale
```
## Client Configuration
### iOS
1. Settings → Calendar → Accounts → Add Account
2. Select "CalDAV"
3. Server: `caldav.daarion.space`
4. Username/Password: Your credentials
### Android (DAVDroid)
1. Install DAVdroid from F-Droid
2. Add Account → CalDAV
3. Server URL: `https://caldav.daarion.space`
### macOS
1. Calendar → Preferences → Accounts
2. Add Account → CalDAV
3. Server: `https://caldav.daarion.space`
### Thunderbird
1. Calendar → New Calendar
2. On the Network → CalDAV
3. Location: `https://caldav.daarion.space/username/`
## Security
### Network Isolation
- Radicale listens only on internal Docker network
- Caddy handles all external traffic
- TLS 1.3 enforced by Caddy
### Authentication
- HTTP Basic Auth (username/password)
- Each user has isolated calendar space (`/username/`)
- Credentials stored in Radicale config
### Firewall Rules
Only allow:
- Port 443 (HTTPS) - public
- Port 5232 - internal only (localhost)
## Backup & Recovery
### Backup Script
```bash
#!/bin/bash
# backup-calendar.sh
docker cp daarion-radicale:/data /backup/calendar-data
tar -czf calendar-backup-$(date +%Y%m%d).tar.gz /backup/calendar-data
```
### Restore
```bash
docker cp /backup/calendar-data/. daarion-radicale:/data/
docker restart daarion-radicale
```
## Monitoring
### Health Checks
- Radicale: `docker inspect --format='{{.State.Health.Status}}' daarion-radicale`
- Caddy: `curl -f http://localhost:8080/health || exit 1`
### Metrics
- Calendar Service: `GET /metrics`
- Account count, pending reminders
## Troubleshooting
### Common Issues
#### "Cannot connect to CalDAV server"
1. Check Caddy is running: `docker ps | grep caddy`
2. Check DNS: `nslookup caldav.daarion.space`
3. Check TLS: `curl -vI https://caldav.daarion.space`
#### "Authentication failed"
1. Check credentials in Radicale container
2. Verify user exists: `ls /data/`
3. Check Caddy logs: `docker logs daarion-caldav-proxy`
#### "Calendar not syncing"
1. Force refresh on client
2. Check network connectivity
3. Verify SSL certificate: `openssl s_client -connect caldav.daarion.space:443`
## Future Enhancements
1. **Radicale Cluster** - Multiple Radicale instances with load balancing
2. **Two-Factor Auth** - Add TOTP to CalDAV authentication
3. **Encryption at Rest** - Encrypt calendar data on disk
4. **Audit Logging** - Track all calendar access
5. **Multiple Providers** - Add Google Calendar, iCloud as backup

View File

@@ -0,0 +1,176 @@
# Calendar Tool - Documentation
## Overview
Calendar Tool provides unified calendar management for Sofiia agent via CalDAV protocol. Currently supports Radicale server, extensible to Google Calendar, iCloud, etc.
## Architecture
```
┌─────────────┐ CalDAV ┌─────────────┐
│ Sofiia │ ──────────────► │ Radicale │
│ Agent │ ◄────────────── │ Server │
└─────────────┘ └─────────────┘
┌─────────────────────────┐
│ Calendar Service │
│ (FastAPI) │
├─────────────────────────┤
│ • /v1/calendar/* │
│ • /v1/tools/calendar │
│ • Reminder Worker │
└─────────────────────────┘
```
## Configuration
### Environment Variables
```bash
# Radicale Server URL
RADICALE_URL=https://caldav.daarion.space
# Database
DATABASE_URL=sqlite:///./calendar.db
```
## API Endpoints
### Connection Management
#### Connect Radicale Account
```bash
POST /v1/calendar/connect/radicale
{
"workspace_id": "ws1",
"user_id": "user1",
"username": "calendar_user",
"password": "secure_password"
}
```
#### List Accounts
```bash
GET /v1/calendar/accounts?workspace_id=ws1&user_id=user1
```
### Calendar Operations
#### List Calendars
```bash
GET /v1/calendar/calendars?account_id=acc_1
```
#### List Events
```bash
GET /v1/calendar/events?account_id=acc_1&time_min=2024-01-01&time_max=2024-12-31
```
#### Create Event
```bash
POST /v1/calendar/events?account_id=acc_1
{
"title": "Meeting with Team",
"start": "2024-01-15T10:00:00",
"end": "2024-01-15T11:00:00",
"timezone": "Europe/Kiev",
"location": "Conference Room A",
"description": "Weekly sync",
"attendees": ["team@example.com"]
}
```
#### Update Event
```bash
PATCH /v1/calendar/events/{uid}?account_id=acc_1
{
"title": "Updated Title",
"description": "New description"
}
```
#### Delete Event
```bash
DELETE /v1/calendar/events/{uid}?account_id=acc_1
```
### Reminders
#### Set Reminder
```bash
POST /v1/calendar/reminders?account_id=acc_1
{
"event_uid": "evt-123",
"remind_at": "2024-01-15T09:00:00",
"channel": "inapp" # inapp, telegram, email
}
```
## Unified Tool Endpoint
For Sofiia agent, use the unified `/v1/tools/calendar` endpoint:
```bash
POST /v1/tools/calendar
{
"action": "create_event",
"workspace_id": "ws1",
"user_id": "user1",
"account_id": "acc_1",
"params": {
"title": "Doctor Appointment",
"start": "2024-02-01T14:00:00",
"end": "2024-02-01T14:30:00",
"timezone": "Europe/Kiev"
}
}
```
### Available Actions
| Action | Description | Required Params |
|--------|-------------|-----------------|
| `connect` | Connect Radicale account | `username`, `password` |
| `list_calendars` | List calendars | `account_id` |
| `list_events` | List events | `account_id`, `calendar_id` (optional) |
| `get_event` | Get single event | `account_id`, `uid` |
| `create_event` | Create event | `account_id`, `title`, `start`, `end` |
| `update_event` | Update event | `account_id`, `uid` |
| `delete_event` | Delete event | `account_id`, `uid` |
| `set_reminder` | Set reminder | `account_id`, `event_uid`, `remind_at` |
## Deployment
### Docker Compose
```bash
cd ops
docker-compose -f docker-compose.calendar.yml up -d
```
This starts:
- Radicale CalDAV server on port 5232
- Caddy reverse proxy with TLS on port 8443
### Local Development
```bash
cd services/calendar-service
pip install -r requirements.txt
uvicorn main:app --reload --port 8001
```
## Testing
```bash
cd services/calendar-service
pytest tests/ -v
```
## Security Notes
- Passwords are stored in plaintext (in production, use encryption)
- Caddy handles TLS termination
- Radicale uses HTTP Basic Auth
- No external API dependencies (self-hosted)

View File

@@ -0,0 +1,639 @@
"""
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()
}

View File

@@ -0,0 +1,139 @@
"""
Reminder Worker - Background worker for calendar reminders
Polls for pending reminders and sends notifications
"""
import logging
import time
import threading
from datetime import datetime
from typing import Dict, Any
logger = logging.getLogger(__name__)
class ReminderWorker:
"""
Background worker that processes calendar reminders.
Runs in background thread, polling for pending reminders
and sending notifications via configured channels.
"""
def __init__(self, storage, poll_interval: int = 60):
self.storage = storage
self.poll_interval = poll_interval
self.running = False
self.thread = None
self.notification_handler = None
# Stats
self.processed_count = 0
self.failed_count = 0
self.last_run = None
def start(self):
"""Start the worker thread"""
if self.running:
logger.warning("Worker already running")
return
self.running = True
self.thread = threading.Thread(target=self._run_loop, daemon=True)
self.thread.start()
logger.info("Reminder worker started")
def stop(self):
"""Stop the worker thread"""
self.running = False
if self.thread:
self.thread.join(timeout=5)
logger.info("Reminder worker stopped")
def _run_loop(self):
"""Main worker loop"""
while self.running:
try:
self._process_reminders()
except Exception as e:
logger.error(f"Error in reminder loop: {e}")
# Sleep until next poll
time.sleep(self.poll_interval)
def _process_reminders(self):
"""Process pending reminders"""
pending = self.storage.get_pending_reminders()
if not pending:
return
logger.info(f"Processing {len(pending)} pending reminders")
for reminder in pending:
try:
self._send_reminder(reminder)
self.storage.update_reminder_status(reminder.id, "sent")
self.processed_count += 1
except Exception as e:
logger.error(f"Failed to send reminder {reminder.id}: {e}")
self.storage.update_reminder_status(
reminder.id,
"failed" if reminder.attempts >= 3 else "pending",
str(e)
)
self.failed_count += 1
self.last_run = datetime.utcnow()
def _send_reminder(self, reminder):
"""Send reminder via appropriate channel"""
# Get event details
# In production, fetch event from CalDAV
event_info = {
"uid": reminder.event_uid,
"user_id": reminder.user_id,
"workspace_id": reminder.workspace_id
}
if reminder.channel == "inapp":
self._send_inapp(reminder, event_info)
elif reminder.channel == "telegram":
self._send_telegram(reminder, event_info)
elif reminder.channel == "email":
self._send_email(reminder, event_info)
else:
logger.warning(f"Unknown channel: {reminder.channel}")
def _send_inapp(self, reminder, event_info):
"""Send in-app notification"""
# In production, would send to notification service
logger.info(f"[INAPP] Reminder for event {reminder.event_uid}")
def _send_telegram(self, reminder, event_info):
"""Send Telegram notification"""
# In production, use Telegram bot API
logger.info(f"[TELEGRAM] Reminder for event {reminder.event_uid}")
def _send_email(self, reminder, event_info):
"""Send email notification"""
# In production, use email service
logger.info(f"[EMAIL] Reminder for event {reminder.event_uid}")
def get_status(self) -> Dict[str, Any]:
"""Get worker status"""
return {
"running": self.running,
"processed_count": self.processed_count,
"failed_count": self.failed_count,
"last_run": self.last_run.isoformat() if self.last_run else None,
"poll_interval": self.poll_interval
}
def set_notification_handler(self, handler):
"""Set custom notification handler"""
self.notification_handler = handler

View File

@@ -0,0 +1,12 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
sqlalchemy==2.0.25
requests==2.31.0
python-dateutil==2.8.2
httpx==0.26.0
# Testing
pytest==8.0.0
pytest-asyncio==0.23.3
pytest-mock==3.12.0

View File

@@ -0,0 +1,243 @@
"""
Calendar Service Tests
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timedelta
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from main import app, CalendarToolRequest, CreateEventRequest, ConnectRequest
from storage import CalendarStorage, CalendarAccount, CalendarReminder
from calendar_client import CalDAVClient
class TestCalendarStorage:
"""Test CalendarStorage"""
def test_create_account(self):
"""Test creating calendar account"""
storage = CalendarStorage()
account = storage.create_account(
workspace_id="ws1",
user_id="user1",
provider="radicale",
username="testuser",
password="testpass",
principal_url="/testuser/",
default_calendar_id="default"
)
assert account.id.startswith("acc_")
assert account.workspace_id == "ws1"
assert account.user_id == "user1"
assert account.provider == "radicale"
assert account.username == "testuser"
def test_get_account(self):
"""Test getting account by ID"""
storage = CalendarStorage()
account = storage.create_account(
workspace_id="ws1",
user_id="user1",
provider="radicale",
username="testuser",
password="testpass"
)
retrieved = storage.get_account(account.id)
assert retrieved is not None
assert retrieved.id == account.id
def test_list_accounts(self):
"""Test listing accounts for user"""
storage = CalendarStorage()
storage.create_account("ws1", "user1", "radicale", "user1", "pass1")
storage.create_account("ws1", "user1", "google", "user1@gmail.com", "pass2")
storage.create_account("ws1", "user2", "radicale", "user2", "pass3")
accounts = storage.list_accounts("ws1", "user1")
assert len(accounts) == 2
def test_create_reminder(self):
"""Test creating reminder"""
storage = CalendarStorage()
account = storage.create_account(
workspace_id="ws1",
user_id="user1",
provider="radicale",
username="testuser",
password="testpass"
)
reminder = storage.create_reminder(
workspace_id="ws1",
user_id="user1",
account_id=account.id,
event_uid="evt123",
remind_at=(datetime.utcnow() + timedelta(hours=1)).isoformat(),
channel="inapp"
)
assert reminder.id.startswith("rem_")
assert reminder.event_uid == "evt123"
assert reminder.status == "pending"
def test_idempotency_key(self):
"""Test idempotency key storage"""
storage = CalendarStorage()
storage.store_idempotency_key(
key="unique-key-123",
workspace_id="ws1",
user_id="user1",
event_uid="evt123"
)
result = storage.get_by_idempotency_key("unique-key-123")
assert result is not None
assert result["event_uid"] == "evt123"
class TestCalDAVClient:
"""Test CalDAV Client"""
def test_client_init(self):
"""Test client initialization"""
client = CalDAVClient(
server_url="https://caldav.example.com",
username="testuser",
password="testpass"
)
assert client.server_url == "https://caldav.example.com"
assert client.username == "testuser"
assert client.principal_url is None
def test_discover_principal(self):
"""Test principal discovery"""
client = CalDAVClient(
server_url="https://caldav.example.com",
username="testuser",
password="testpass"
)
with patch.object(client, '_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 207
mock_request.return_value = mock_response
principal = client.discover_principal()
assert principal == "/testuser/"
def test_build_vevent(self):
"""Test VEVENT building"""
client = CalDAVClient(
server_url="https://caldav.example.com",
username="testuser",
password="testpass"
)
vevent = client._build_vevent(
uid="test-uid-123",
title="Test Event",
start="2024-01-15T10:00:00",
end="2024-01-15T11:00:00",
timezone="Europe/Kiev",
location="Office",
description="Test description",
attendees=["test@example.com"]
)
assert "BEGIN:VCALENDAR" in vevent
assert "BEGIN:VEVENT" in vevent
assert "SUMMARY:Test Event" in vevent
assert "LOCATION:Office" in vevent
assert "ATTENDEE:mailto:test@example.com" in vevent
assert "UID:test-uid-123" in vevent
def test_parse_vevent(self):
"""Test VEVENT parsing"""
client = CalDAVClient(
server_url="https://caldav.example.com",
username="testuser",
password="testpass"
)
ics_data = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:test-uid-456
SUMMARY:Parsed Event
DTSTART:20240115T100000
DTEND:20240115T110000
LOCATION:Home
DESCRIPTION:Test description
END:VEVENT
END:VCALENDAR"""
event = client._parse_vevent(ics_data)
assert event["uid"] == "test-uid-456"
assert event["title"] == "Parsed Event"
assert event["location"] == "Home"
class TestCalendarToolEndpoint:
"""Test calendar tool API endpoint"""
@pytest.fixture
def client(self):
"""Test client fixture"""
from fastapi.testclient import TestClient
return TestClient(app)
def test_health_check(self, client):
"""Test health endpoint"""
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
def test_metrics(self, client):
"""Test metrics endpoint"""
response = client.get("/metrics")
assert response.status_code == 200
data = response.json()
assert "accounts_count" in data
assert "reminders_pending" in data
@patch('main.CalDAVClient')
def test_connect_radicale(self, mock_caldav_class, client):
"""Test connecting Radicale account"""
mock_client = Mock()
mock_client.list_calendars.return_value = [
{"id": "default", "display_name": "Default Calendar"}
]
mock_client.principal_url = "/testuser/"
mock_caldav_class.return_value = mock_client
response = client.post(
"/v1/calendar/connect/radicale",
json={
"workspace_id": "ws1",
"user_id": "user1",
"username": "testuser",
"password": "testpass"
}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "connected"
assert "account_id" in data
if __name__ == "__main__":
pytest.main([__file__, "-v"])