|
|
@@ -1,304 +0,0 @@
|
|
|
-import asyncio
|
|
|
-from datetime import date
|
|
|
-from fastmcp import FastMCP
|
|
|
-from room_cal._utils import services as _services, service_name as _service_name, safe_json_loads, iter_dates
|
|
|
-from room_cal._health import store_check, credentials_checks
|
|
|
-from room_cal.pricing import CalPrice
|
|
|
-from room_cal.pricing.calc import (
|
|
|
- net_to_list, list_to_net, list_to_retail,
|
|
|
- retail_to_list, net_to_retail, retail_to_net,
|
|
|
-)
|
|
|
-from room_cal.reservations import Reservation
|
|
|
-from room_cal.closed import Closed
|
|
|
-from room_cal.discount import Discount
|
|
|
-
|
|
|
-mcp = FastMCP('room_cal')
|
|
|
-
|
|
|
-_CALC_FROM = {
|
|
|
- 'net_to_list': 'net', 'net_to_retail': 'net',
|
|
|
- 'list_to_net': 'list', 'list_to_retail': 'list',
|
|
|
- 'retail_to_list': 'retail', 'retail_to_net': 'retail',
|
|
|
-}
|
|
|
-_API_SOURCE = {'domain': 'mcp', 'id': ''}
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def health_check() -> dict:
|
|
|
- """Check store connectivity and service credential validity. Returns {store_connected, credentials}."""
|
|
|
- store_result, creds = await asyncio.gather(
|
|
|
- store_check(),
|
|
|
- credentials_checks(),
|
|
|
- )
|
|
|
- return {
|
|
|
- 'store_connected': store_result.to_dict(),
|
|
|
- 'credentials': {k: v.to_dict() for k, v in creds.items()},
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def list_services() -> list[dict]:
|
|
|
- return [{'key': s, 'name': _service_name(s)} for s in _services()]
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def convert_price(transformation: str, service: str, amount: int) -> dict:
|
|
|
- """Convert a price. transformation: net_to_list, list_to_net, list_to_retail, retail_to_list, net_to_retail, retail_to_net. Returns {net, list, retail}."""
|
|
|
- from_type = _CALC_FROM.get(transformation)
|
|
|
- if from_type is None:
|
|
|
- raise ValueError(f'Unknown transformation: {transformation!r}')
|
|
|
- if from_type == 'net':
|
|
|
- return {'net': amount, 'list': net_to_list(service, amount), 'retail': net_to_retail(service, amount)}
|
|
|
- if from_type == 'list':
|
|
|
- return {'net': list_to_net(service, amount), 'list': amount, 'retail': list_to_retail(service, amount)}
|
|
|
- return {'net': retail_to_net(service, amount), 'list': retail_to_list(service, amount), 'retail': amount}
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_prices(start_date: str, end_date: str) -> dict:
|
|
|
- """Get price dicts for a date range (YYYY-MM-DD). Returns a dict keyed by date string; None for dates with no price set."""
|
|
|
- price_range = await CalPrice().get(date.fromisoformat(start_date), date.fromisoformat(end_date))
|
|
|
- return {k: v.to_dict() if v else None for k, v in price_range.items()}
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def set_price(target_date: str, value: float) -> None:
|
|
|
- """Set the net price for a date (YYYY-MM-DD)."""
|
|
|
- await CalPrice().set(date.fromisoformat(target_date), value)
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def patch_reservation(uid: str, checkin_date: str, fields: dict[str, str] | None = None,
|
|
|
- checkin_time: str | None = None, checkout_time: str | None = None) -> dict:
|
|
|
- """Patch a stored reservation. checkin_date (YYYY-MM-DD) is required to locate the event.
|
|
|
-
|
|
|
- `fields` is merged directly into the reservation's stored data — wifi_key, wifi_ssid,
|
|
|
- door_code, or any other custom field — except the sync-owned identity fields
|
|
|
- (reservation_id, service, service_data, chat_id, guest_name), which are rejected.
|
|
|
- `checkin_time`/`checkout_time` are 'HH:MM' strings that adjust only the time-of-day
|
|
|
- of checkin/checkout; the date is never changed. Returns the updated reservation dict.
|
|
|
- """
|
|
|
- result = await Reservation().patch(
|
|
|
- uid, date.fromisoformat(checkin_date),
|
|
|
- fields=fields, checkin_time=checkin_time, checkout_time=checkout_time,
|
|
|
- )
|
|
|
- if result is None:
|
|
|
- raise ValueError(f'reservation {uid!r} not found on {checkin_date}')
|
|
|
- return result
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_reservation(target_date: str) -> dict | None:
|
|
|
- """Get the reservation covering a date (checkin ≤ date < checkout), or null."""
|
|
|
- d = date.fromisoformat(target_date)
|
|
|
- for ev in await Reservation().list(d, d):
|
|
|
- if ev['start'].date() <= d < ev['end'].date():
|
|
|
- xd = safe_json_loads(ev['x_data']) or {}
|
|
|
- xd['checkin_date'] = ev['start'].date().isoformat()
|
|
|
- xd['checkout_date'] = ev['end'].date().isoformat()
|
|
|
- return xd
|
|
|
- return None
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_reservations_range(start_date: str, end_date: str) -> dict:
|
|
|
- """Get reservations for each date in a range. Returns {date: dict or null}."""
|
|
|
- start = date.fromisoformat(start_date)
|
|
|
- end = date.fromisoformat(end_date)
|
|
|
- events = await Reservation().list(start, end)
|
|
|
- result: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
|
|
|
- for ev in events:
|
|
|
- checkin = ev['start'].date()
|
|
|
- checkout = ev['end'].date()
|
|
|
- xd = safe_json_loads(ev['x_data']) or {}
|
|
|
- xd['checkin_date'] = checkin.isoformat()
|
|
|
- xd['checkout_date'] = checkout.isoformat()
|
|
|
- for d in iter_dates(start, end):
|
|
|
- if checkin <= d < checkout:
|
|
|
- result[str(d)] = xd
|
|
|
- return result
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_closed(target_date: str) -> list[str]:
|
|
|
- """Get closed service keys for a date. Empty list means open."""
|
|
|
- return await Closed().get(date.fromisoformat(target_date)) or []
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_closed_range(start_date: str, end_date: str) -> dict:
|
|
|
- """Get closed services for each date in a range. Returns {date: [service, ...]}."""
|
|
|
- start = date.fromisoformat(start_date)
|
|
|
- end = date.fromisoformat(end_date)
|
|
|
- events = await Closed().list(start, end)
|
|
|
- by_date: dict[str, list] = {str(d): [] for d in iter_dates(start, end)}
|
|
|
- for ev in events:
|
|
|
- d = ev['start'].date()
|
|
|
- if start <= d <= end:
|
|
|
- xd = safe_json_loads(ev['x_data'])
|
|
|
- by_date[str(d)] = (xd or {}).get('closed_services', [])
|
|
|
- return by_date
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def set_closed(target_date: str, services: list[str]) -> None:
|
|
|
- """Block a date for the given services. Empty list unblocks the date."""
|
|
|
- await Closed().set(date.fromisoformat(target_date), services, source=_API_SOURCE)
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_discount(target_date: str) -> dict | None:
|
|
|
- """Get discount for a date, or null if none set."""
|
|
|
- return await Discount().get(date.fromisoformat(target_date))
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_discounts_range(start_date: str, end_date: str) -> dict:
|
|
|
- """Get discounts for each date in a range. Returns {date: {type, value} or null}."""
|
|
|
- start = date.fromisoformat(start_date)
|
|
|
- end = date.fromisoformat(end_date)
|
|
|
- events = await Discount().list(start, end)
|
|
|
- by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
|
|
|
- for ev in events:
|
|
|
- d = ev['start'].date()
|
|
|
- if start <= d <= end:
|
|
|
- xd = safe_json_loads(ev['x_data'])
|
|
|
- if xd:
|
|
|
- by_date[str(d)] = {'type': xd.get('type'), 'value': xd.get('value')}
|
|
|
- return by_date
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def set_discount(target_date: str, discount_type: str, value: int) -> None:
|
|
|
- """Set a discount. discount_type: 'percent' or 'flat'."""
|
|
|
- await Discount().set(
|
|
|
- date.fromisoformat(target_date), discount_type, value, source=_API_SOURCE
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def delete_discount(target_date: str) -> None:
|
|
|
- """Remove the discount on a date."""
|
|
|
- await Discount().delete_on(date.fromisoformat(target_date))
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def refresh_credentials(service: str | None = None) -> dict:
|
|
|
- """Trigger automatic credential refresh via Playwright. service: optional key (e.g. 'booking', 'airbnb'); omit to refresh all. Booking.com includes messaging 2FA and may take 2+ minutes. Returns {refreshed: [...], errors: {key: reason}}."""
|
|
|
- from room_cal.service_api import ServiceAPI
|
|
|
- from room_cal.service_api.types import ServiceAuthError
|
|
|
- keys = [service] if service else _services()
|
|
|
- api = ServiceAPI.get()
|
|
|
-
|
|
|
- async def _do(key: str) -> tuple[str, str | None]:
|
|
|
- try:
|
|
|
- await api.refresh_credentials(key)
|
|
|
- return key, None
|
|
|
- except (ServiceAuthError, Exception) as e:
|
|
|
- return key, str(e)
|
|
|
-
|
|
|
- results = await asyncio.gather(*[_do(k) for k in keys])
|
|
|
- return {
|
|
|
- 'refreshed': [k for k, err in results if err is None],
|
|
|
- 'errors': {k: err for k, err in results if err is not None},
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def reload_caches() -> dict:
|
|
|
- """Clear all in-memory CalDAV event caches so the next request re-fetches from the server. Returns {cleared_calendars}."""
|
|
|
- from room_cal.store import clear_caches
|
|
|
- count = clear_caches()
|
|
|
- return {'cleared_calendars': count}
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def get_chat_thread(service_key: str, chat_id: str) -> dict:
|
|
|
- """Fetch recent messages for a reservation thread. Returns {chat_id, messages: [{id, created_at, body, sender}], is_reply_needed?}."""
|
|
|
- from room_cal.service_api import ServiceAPI
|
|
|
- from room_cal.service_api.types import ServiceAuthError
|
|
|
- api = ServiceAPI.get()
|
|
|
- try:
|
|
|
- client = api.chat_client(service_key)
|
|
|
- except KeyError:
|
|
|
- raise ValueError(f'unknown service: {service_key!r}')
|
|
|
- except TypeError:
|
|
|
- raise ValueError(f'{service_key!r} does not support chat')
|
|
|
- try:
|
|
|
- thread = await client.get_thread(chat_id)
|
|
|
- except ServiceAuthError as e:
|
|
|
- raise RuntimeError(f'auth error: {e}')
|
|
|
- chat_id_out = getattr(thread, 'thread_id', None) or getattr(thread, 'reservation_id', None)
|
|
|
- result: dict = {
|
|
|
- 'chat_id': chat_id_out,
|
|
|
- 'messages': [
|
|
|
- {
|
|
|
- 'id': m.id,
|
|
|
- 'created_at': getattr(m, 'created_at_ms', None) or getattr(m, 'created_at', None),
|
|
|
- 'body': m.body,
|
|
|
- 'sender': getattr(m, 'sender_name', None) or getattr(m, 'sender', None),
|
|
|
- }
|
|
|
- for m in thread.messages
|
|
|
- ],
|
|
|
- }
|
|
|
- if hasattr(thread, 'is_reply_needed'):
|
|
|
- result['is_reply_needed'] = thread.is_reply_needed
|
|
|
- return result
|
|
|
-
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def send_chat_message(service_key: str, chat_id: str, body: str) -> None:
|
|
|
- """Send a text message to a guest conversation thread."""
|
|
|
- from room_cal.service_api import ServiceAPI
|
|
|
- from room_cal.service_api.types import ServiceAuthError
|
|
|
- api = ServiceAPI.get()
|
|
|
- try:
|
|
|
- client = api.chat_client(service_key)
|
|
|
- except KeyError:
|
|
|
- raise ValueError(f'unknown service: {service_key!r}')
|
|
|
- except TypeError:
|
|
|
- raise ValueError(f'{service_key!r} does not support chat')
|
|
|
- try:
|
|
|
- await client.send_message(chat_id, body)
|
|
|
- except ServiceAuthError as e:
|
|
|
- raise RuntimeError(f'auth error: {e}')
|
|
|
-
|
|
|
-
|
|
|
-import importlib
|
|
|
-import pkgutil
|
|
|
-import room_cal.actions as _actions_pkg
|
|
|
-
|
|
|
-_ACTIONS: dict[str, tuple[str, str, list[str]]] = {}
|
|
|
-for _info in pkgutil.iter_modules(_actions_pkg.__path__):
|
|
|
- if _info.name.startswith('_'):
|
|
|
- continue
|
|
|
- _mod = importlib.import_module(f'room_cal.actions.{_info.name}')
|
|
|
- _spec = getattr(_mod, 'ACTION_SPEC', None)
|
|
|
- if _spec:
|
|
|
- _route_name, _allowed_keys = _spec
|
|
|
- _ACTIONS[_route_name] = (f'room_cal.actions.{_info.name}', _info.name, _allowed_keys)
|
|
|
-
|
|
|
-@mcp.tool()
|
|
|
-async def run_action(
|
|
|
- action: str,
|
|
|
- start_date: str,
|
|
|
- end_date: str | None = None,
|
|
|
- feed_name: str | None = None,
|
|
|
- title: str | None = None,
|
|
|
- service: str | None = None,
|
|
|
- weekday: int | None = None,
|
|
|
- weekend: int | None = None,
|
|
|
-) -> object:
|
|
|
- """Run a room_cal action over a date range. action must be one of the names in _ACTIONS (auto-discovered from room_cal.actions). Optional kwargs: feed_name, title (fetch-feed); service (fetch-prices); weekday, weekend (fill-prices)."""
|
|
|
- from datetime import date as _date
|
|
|
- if action not in _ACTIONS:
|
|
|
- raise ValueError(f'Unknown action: {action!r}')
|
|
|
- module_path, fn_name, allowed_keys = _ACTIONS[action]
|
|
|
- mod = importlib.import_module(module_path)
|
|
|
- fn = getattr(mod, fn_name)
|
|
|
- start = _date.fromisoformat(start_date)
|
|
|
- end = _date.fromisoformat(end_date) if end_date else start
|
|
|
- all_kwargs = {'feed_name': feed_name, 'title': title, 'service': service, 'weekday': weekday, 'weekend': weekend}
|
|
|
- kwargs = {k: v for k, v in all_kwargs.items() if k in allowed_keys and v is not None}
|
|
|
- return await fn(start, end, **kwargs)
|