Files
2026-07-13 13:39:38 +08:00

255 lines
8.8 KiB
Python

from __future__ import annotations
import base64
import datetime
import hashlib
import logging
import random
from dataclasses import dataclass
from typing import Protocol
from urllib.parse import urlencode
from zoneinfo import ZoneInfo
import aiohttp
from livekit.agents.utils import http_context
class SlotUnavailableError(Exception):
def __init__(self, message: str) -> None:
super().__init__(message)
@dataclass
class AvailableSlot:
start_time: datetime.datetime
duration_min: int
@property
def unique_hash(self) -> str:
# unique id based on the start_time & duration_min
raw = f"{self.start_time.isoformat()}|{self.duration_min}".encode()
digest = hashlib.blake2s(raw, digest_size=5).digest()
return f"ST_{base64.b32encode(digest).decode().rstrip('=').lower()}"
@dataclass
class BookedAppointment:
slot: AvailableSlot
attendee_email: str
class Calendar(Protocol):
# The bookings made this session. The calendar is the single system of
# record, so callers never keep a parallel list that could drift.
scheduled_appointments: list[BookedAppointment]
def now(self) -> datetime.datetime:
"""The calendar's current time (tz-aware). Wall-clock in production;
a fixed value under simulation so scenario dates stay deterministic."""
...
async def initialize(self) -> None: ...
async def schedule_appointment(
self,
*,
start_time: datetime.datetime,
attendee_email: str,
) -> None: ...
async def list_available_slots(
self, *, start_time: datetime.datetime, end_time: datetime.datetime
) -> list[AvailableSlot]: ...
class FakeCalendar(Calendar):
def __init__(
self,
*,
timezone: str,
slots: list[AvailableSlot] | None = None,
seed: int | None = None,
now: datetime.datetime | None = None,
) -> None:
self.tz = ZoneInfo(timezone)
# A fixed clock keeps simulation scenarios deterministic; None means
# follow the wall clock.
self._now = now
self._slots: list[AvailableSlot] = []
self.scheduled_appointments: list[BookedAppointment] = []
if slots is not None:
self._slots.extend(slots)
return
rng = random.Random(seed)
today = self.now().date()
for day_offset in range(1, 90): # generate slots for the next 90 days
current_day = today + datetime.timedelta(days=day_offset)
if current_day.weekday() >= 5:
continue
# build all possible 30-min slots between 09:00 and 17:00
day_start = datetime.datetime.combine(current_day, datetime.time(9, 0), tzinfo=self.tz)
slots_in_day = [
day_start + datetime.timedelta(minutes=30 * i)
for i in range(int((17 - 9) * 2)) # (17-9)=8 hours => 16 slots
]
num_slots = rng.randint(3, 6)
chosen = rng.sample(slots_in_day, num_slots)
for slot_start in sorted(chosen):
self._slots.append(AvailableSlot(start_time=slot_start, duration_min=30))
def now(self) -> datetime.datetime:
if self._now is not None:
return self._now.astimezone(self.tz)
return datetime.datetime.now(self.tz)
async def initialize(self) -> None:
pass
async def schedule_appointment(
self, *, start_time: datetime.datetime, attendee_email: str
) -> None:
slot = next((s for s in self._slots if s.start_time == start_time), None)
if slot is None:
raise SlotUnavailableError(f"no available slot at {start_time.isoformat()}")
self._slots.remove(slot)
self.scheduled_appointments.append(
BookedAppointment(slot=slot, attendee_email=attendee_email)
)
async def list_available_slots(
self, *, start_time: datetime.datetime, end_time: datetime.datetime
) -> list[AvailableSlot]:
return [slot for slot in self._slots if start_time <= slot.start_time < end_time]
# --- cal.com impl ---
CAL_COM_EVENT_TYPE = "livekit-front-desk"
EVENT_DURATION_MIN = 30
BASE_URL = "https://api.cal.com/v2/"
class CalComCalendar(Calendar):
def __init__(self, *, api_key: str, timezone: str) -> None:
self.tz = ZoneInfo(timezone)
self._api_key = api_key
self.scheduled_appointments: list[BookedAppointment] = []
try:
self._http_session = http_context.http_session()
except RuntimeError:
self._http_session = aiohttp.ClientSession()
self._logger = logging.getLogger("cal.com")
def now(self) -> datetime.datetime:
return datetime.datetime.now(self.tz)
async def initialize(self) -> None:
async with self._http_session.get(
headers=self._build_headers(api_version="2024-06-14"), url=f"{BASE_URL}me/"
) as resp:
resp.raise_for_status()
username = (await resp.json())["data"]["username"]
self._logger.info(f"using cal.com username: {username}")
query = urlencode({"username": username})
async with self._http_session.get(
headers=self._build_headers(api_version="2024-06-14"),
url=f"{BASE_URL}event-types/?{query}",
) as resp:
resp.raise_for_status()
data = (await resp.json())["data"]
lk_event_type = next(
(event for event in data if event.get("slug") == CAL_COM_EVENT_TYPE), None
)
if lk_event_type:
self._lk_event_id = lk_event_type["id"]
else:
async with self._http_session.post(
headers=self._build_headers(api_version="2024-06-14"),
url=f"{BASE_URL}event-types",
json={
"lengthInMinutes": EVENT_DURATION_MIN,
"title": "LiveKit Front-Desk",
"slug": CAL_COM_EVENT_TYPE,
},
) as resp:
resp.raise_for_status()
self._logger.info(f"successfully added {CAL_COM_EVENT_TYPE} event type")
data = (await resp.json())["data"]
self._lk_event_id = data["id"]
self._logger.info(f"event type id: {self._lk_event_id}")
async def schedule_appointment(
self, *, start_time: datetime.datetime, attendee_email: str
) -> None:
slot = AvailableSlot(start_time=start_time, duration_min=EVENT_DURATION_MIN)
start_time = start_time.astimezone(datetime.timezone.utc)
async with self._http_session.post(
headers=self._build_headers(api_version="2024-08-13"),
url=f"{BASE_URL}bookings",
json={
"start": start_time.isoformat(),
"attendee": {
"name": attendee_email, # TODO(theomonnom): add name prompt
"email": attendee_email,
"timeZone": self.tz.tzname(None),
},
"eventTypeId": self._lk_event_id,
},
) as resp:
data = await resp.json()
if error := data.get("error"):
message = error["message"]
if "User either already has booking at this time or is not available" in message:
raise SlotUnavailableError(error["message"])
resp.raise_for_status()
self.scheduled_appointments.append(
BookedAppointment(slot=slot, attendee_email=attendee_email)
)
async def list_available_slots(
self, *, start_time: datetime.datetime, end_time: datetime.datetime
) -> list[AvailableSlot]:
start_time = start_time.astimezone(datetime.timezone.utc)
end_time = end_time.astimezone(datetime.timezone.utc)
query = urlencode(
{
"eventTypeId": self._lk_event_id,
"start": start_time.isoformat(),
"end": end_time.isoformat(),
}
)
async with self._http_session.get(
headers=self._build_headers(api_version="2024-09-04"), url=f"{BASE_URL}slots/?{query}"
) as resp:
resp.raise_for_status()
raw_data = (await resp.json())["data"]
available_slots = []
for _, slots in raw_data.items():
for slot in slots:
start_dt = datetime.datetime.fromisoformat(slot["start"].replace("Z", "+00:00"))
available_slots.append(
AvailableSlot(start_time=start_dt, duration_min=EVENT_DURATION_MIN)
)
return available_slots
def _build_headers(self, *, api_version: str | None = None) -> dict[str, str]:
h = {"Authorization": f"Bearer {self._api_key}"}
if api_version:
h["cal-api-version"] = api_version
return h