booksy-api-mcp 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- booksy_api_mcp/__init__.py +28 -0
- booksy_api_mcp/api.py +212 -0
- booksy_api_mcp/app.py +41 -0
- booksy_api_mcp/booksy/__init__.py +0 -0
- booksy_api_mcp/booksy/auth.py +33 -0
- booksy_api_mcp/booksy/calendar.py +247 -0
- booksy_api_mcp/booksy/client.py +100 -0
- booksy_api_mcp/booksy/clients.py +172 -0
- booksy_api_mcp/booksy/engagement.py +191 -0
- booksy_api_mcp/booksy/tools/__init__.py +0 -0
- booksy_api_mcp/booksy/tools/add_client.py +59 -0
- booksy_api_mcp/booksy/tools/add_client_note.py +46 -0
- booksy_api_mcp/booksy/tools/add_time_off.py +170 -0
- booksy_api_mcp/booksy/tools/cancel_appointment.py +73 -0
- booksy_api_mcp/booksy/tools/create_appointment.py +155 -0
- booksy_api_mcp/booksy/tools/delete_client.py +53 -0
- booksy_api_mcp/booksy/tools/estimate_message_blast.py +80 -0
- booksy_api_mcp/booksy/tools/find_client.py +49 -0
- booksy_api_mcp/booksy/tools/find_open_slots.py +209 -0
- booksy_api_mcp/booksy/tools/get_appointment.py +65 -0
- booksy_api_mcp/booksy/tools/get_business_hours.py +64 -0
- booksy_api_mcp/booksy/tools/get_business_profile.py +105 -0
- booksy_api_mcp/booksy/tools/get_client.py +72 -0
- booksy_api_mcp/booksy/tools/get_report.py +114 -0
- booksy_api_mcp/booksy/tools/get_settings.py +77 -0
- booksy_api_mcp/booksy/tools/list_appointments.py +92 -0
- booksy_api_mcp/booksy/tools/list_clients.py +94 -0
- booksy_api_mcp/booksy/tools/list_message_blasts.py +98 -0
- booksy_api_mcp/booksy/tools/list_notifications.py +71 -0
- booksy_api_mcp/booksy/tools/list_reviews.py +102 -0
- booksy_api_mcp/booksy/tools/list_services.py +102 -0
- booksy_api_mcp/booksy/tools/list_staff.py +107 -0
- booksy_api_mcp/booksy/tools/list_time_off.py +107 -0
- booksy_api_mcp/booksy/tools/remove_time_off.py +47 -0
- booksy_api_mcp/booksy/tools/reply_to_review.py +59 -0
- booksy_api_mcp/booksy/tools/reschedule_appointment.py +106 -0
- booksy_api_mcp/booksy/tools/send_message_blast.py +100 -0
- booksy_api_mcp/booksy/tools/update_client.py +50 -0
- booksy_api_mcp/cli.py +204 -0
- booksy_api_mcp/core/__init__.py +0 -0
- booksy_api_mcp/core/account_guard.py +33 -0
- booksy_api_mcp/core/context.py +199 -0
- booksy_api_mcp/core/errors.py +78 -0
- booksy_api_mcp/core/helpers.py +33 -0
- booksy_api_mcp/core/tools.py +106 -0
- booksy_api_mcp/mcp_context.py +126 -0
- booksy_api_mcp/mcp_server.py +67 -0
- booksy_api_mcp/runner.py +109 -0
- booksy_api_mcp/scripting.py +73 -0
- booksy_api_mcp/session.py +257 -0
- booksy_api_mcp-1.0.2.dist-info/METADATA +477 -0
- booksy_api_mcp-1.0.2.dist-info/RECORD +55 -0
- booksy_api_mcp-1.0.2.dist-info/WHEEL +4 -0
- booksy_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
- booksy_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Self-hosted API and MCP server for one app (see app.py). Personal use only, see LICENSE.
|
|
2
|
+
|
|
3
|
+
From Python, without a server:
|
|
4
|
+
|
|
5
|
+
from booksy_api_mcp import call
|
|
6
|
+
services = call("list_services")["services"]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.2"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def call(tool: str, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
18
|
+
"""Run one tool with the saved session and return its answer. Raises ToolError on failure."""
|
|
19
|
+
from booksy_api_mcp.runner import Runner
|
|
20
|
+
|
|
21
|
+
async def go() -> Any:
|
|
22
|
+
runner = Runner()
|
|
23
|
+
try:
|
|
24
|
+
return await runner.run(tool, {**(args or {}), **kwargs})
|
|
25
|
+
finally:
|
|
26
|
+
await runner.close()
|
|
27
|
+
|
|
28
|
+
return asyncio.run(go())
|
booksy_api_mcp/api.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""The REST API and the MCP endpoint on one port.
|
|
2
|
+
|
|
3
|
+
- `GET|POST|PUT|PATCH|DELETE <app.API_PREFIX>/<route>`: each tool at its route, the same paths as the
|
|
4
|
+
hosted API at cotlet.dev. Path parts and the query string are arguments; a JSON body adds the rest.
|
|
5
|
+
- `POST <app.API_PREFIX>/tools/{name}`: any tool by name, the JSON body is its arguments.
|
|
6
|
+
- `GET <app.API_PREFIX>/tools`: every tool with its schema. OpenAPI at `/docs` and `/openapi.json`.
|
|
7
|
+
- `/mcp`: the MCP server over streamable HTTP.
|
|
8
|
+
|
|
9
|
+
With <ENV>_MCP_TOKEN set, every request needs `Authorization: Bearer <token>`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, Request
|
|
23
|
+
from fastapi.responses import JSONResponse
|
|
24
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
25
|
+
|
|
26
|
+
from booksy_api_mcp import __version__, app, mcp_server
|
|
27
|
+
from booksy_api_mcp.core.errors import HTTP_STATUS, ErrorCode, ToolError
|
|
28
|
+
from booksy_api_mcp.core.tools import ToolSpec
|
|
29
|
+
from booksy_api_mcp.runner import Runner, description, tools
|
|
30
|
+
|
|
31
|
+
PREFIX = app.API_PREFIX
|
|
32
|
+
ASGI = Callable[[dict[str, Any], Callable[[], Awaitable[Any]], Callable[[Any], Awaitable[None]]], Awaitable[None]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _error(code: ErrorCode, message: str) -> JSONResponse:
|
|
36
|
+
return JSONResponse({"error": code.value, "message": message}, status_code=HTTP_STATUS.get(code, 500))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _typed(value: str, schema: dict[str, Any]) -> Any:
|
|
40
|
+
kind = schema.get("type")
|
|
41
|
+
try:
|
|
42
|
+
if kind == "integer":
|
|
43
|
+
return int(value)
|
|
44
|
+
if kind == "number":
|
|
45
|
+
return float(value)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return value
|
|
48
|
+
if kind == "boolean":
|
|
49
|
+
return value.lower() in ("1", "true", "yes", "on")
|
|
50
|
+
if kind == "array":
|
|
51
|
+
return [_typed(v, schema.get("items") or {}) for v in value.split(",") if v]
|
|
52
|
+
if kind == "object":
|
|
53
|
+
with contextlib.suppress(ValueError):
|
|
54
|
+
return json.loads(value)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _route_order(spec: ToolSpec) -> list[int]:
|
|
59
|
+
"""Literal segments before {placeholders}, so /clients/search wins over /clients/{client_id}."""
|
|
60
|
+
path = (spec.route or "").split(" ", 1)[1]
|
|
61
|
+
return [1 if part.startswith("{") else 0 for part in path.strip("/").split("/")]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tag(path: str) -> str:
|
|
65
|
+
"""OpenAPI group: the first part of the path (clients, appointments ...)."""
|
|
66
|
+
return path.strip("/").split("/")[0]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _openapi(spec: ToolSpec, method: str, path: str) -> dict[str, Any]:
|
|
70
|
+
props: dict[str, Any] = spec.input_schema.get("properties") or {}
|
|
71
|
+
required = set(spec.input_schema.get("required") or [])
|
|
72
|
+
in_path = set(re.findall(r"\{(\w+)\}", path))
|
|
73
|
+
parameters = [
|
|
74
|
+
{"name": name, "in": "path", "required": True, "schema": props.get(name, {"type": "string"})}
|
|
75
|
+
for name in in_path
|
|
76
|
+
]
|
|
77
|
+
rest = {k: v for k, v in props.items() if k not in in_path}
|
|
78
|
+
extra: dict[str, Any] = {}
|
|
79
|
+
if method == "GET":
|
|
80
|
+
parameters += [
|
|
81
|
+
{"name": name, "in": "query", "required": name in required, "schema": schema}
|
|
82
|
+
for name, schema in rest.items()
|
|
83
|
+
]
|
|
84
|
+
elif rest:
|
|
85
|
+
body = {"type": "object", "properties": rest, "required": sorted(required - in_path)}
|
|
86
|
+
extra["requestBody"] = {"required": bool(body["required"]), "content": {"application/json": {"schema": body}}}
|
|
87
|
+
if spec.example_response:
|
|
88
|
+
extra["responses"] = {
|
|
89
|
+
"200": {"description": "OK", "content": {"application/json": {"example": spec.example_response}}}
|
|
90
|
+
}
|
|
91
|
+
return {"parameters": parameters, **extra}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _arguments(request: Request, spec: ToolSpec) -> dict[str, Any]:
|
|
95
|
+
props: dict[str, Any] = spec.input_schema.get("properties") or {}
|
|
96
|
+
args: dict[str, Any] = {}
|
|
97
|
+
if request.method != "GET":
|
|
98
|
+
raw = await request.body()
|
|
99
|
+
if raw.strip():
|
|
100
|
+
try:
|
|
101
|
+
body = json.loads(raw)
|
|
102
|
+
except ValueError:
|
|
103
|
+
raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT) from None
|
|
104
|
+
if not isinstance(body, dict):
|
|
105
|
+
raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT)
|
|
106
|
+
args.update(body)
|
|
107
|
+
for name in request.query_params:
|
|
108
|
+
values = request.query_params.getlist(name)
|
|
109
|
+
schema = props.get(name) or {}
|
|
110
|
+
args[name] = (
|
|
111
|
+
[_typed(v, schema.get("items") or {}) for v in values]
|
|
112
|
+
if schema.get("type") == "array" and len(values) > 1
|
|
113
|
+
else _typed(values[-1], schema)
|
|
114
|
+
)
|
|
115
|
+
for name, value in request.path_params.items():
|
|
116
|
+
args[name] = _typed(value, props.get(name) or {})
|
|
117
|
+
return args
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def create_app(runner: Runner) -> ASGI:
|
|
121
|
+
manager = StreamableHTTPSessionManager(app=mcp_server.build(runner), json_response=True, stateless=True)
|
|
122
|
+
token = os.environ.get(f"{app.ENV}_MCP_TOKEN", "").strip()
|
|
123
|
+
|
|
124
|
+
@contextlib.asynccontextmanager
|
|
125
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
126
|
+
async with manager.run():
|
|
127
|
+
yield
|
|
128
|
+
await runner.close()
|
|
129
|
+
|
|
130
|
+
api = FastAPI(
|
|
131
|
+
title=f"{app.NAME} API (self-hosted)",
|
|
132
|
+
version=__version__,
|
|
133
|
+
description=f"Your {app.NAME} account as a REST API, run on your machine with your Chrome's session.",
|
|
134
|
+
lifespan=lifespan,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def call(spec: ToolSpec, args: dict[str, Any]) -> JSONResponse:
|
|
138
|
+
try:
|
|
139
|
+
result = await runner.run(spec.name, args)
|
|
140
|
+
except ToolError as exc:
|
|
141
|
+
return _error(exc.code, str(exc))
|
|
142
|
+
return JSONResponse(result)
|
|
143
|
+
|
|
144
|
+
def endpoint(spec: ToolSpec) -> Callable[[Request], Awaitable[JSONResponse]]:
|
|
145
|
+
async def handle(request: Request) -> JSONResponse:
|
|
146
|
+
try:
|
|
147
|
+
args = await _arguments(request, spec)
|
|
148
|
+
except ToolError as exc:
|
|
149
|
+
return _error(exc.code, str(exc))
|
|
150
|
+
return await call(spec, args)
|
|
151
|
+
|
|
152
|
+
return handle
|
|
153
|
+
|
|
154
|
+
@api.get(f"{PREFIX}/tools", summary="Every tool with its route and input schema")
|
|
155
|
+
async def list_tools() -> list[dict[str, Any]]:
|
|
156
|
+
return [
|
|
157
|
+
{
|
|
158
|
+
"name": spec.name,
|
|
159
|
+
"title": spec.title,
|
|
160
|
+
"access": spec.access,
|
|
161
|
+
"route": spec.route,
|
|
162
|
+
"description": description(spec),
|
|
163
|
+
"input_schema": spec.input_schema,
|
|
164
|
+
"example_response": spec.example_response,
|
|
165
|
+
}
|
|
166
|
+
for spec in tools(runner.read_only)
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
@api.post(f"{PREFIX}/tools/{{name}}", summary="Call any tool by name; the JSON body is its arguments")
|
|
170
|
+
async def call_by_name(name: str, request: Request) -> JSONResponse:
|
|
171
|
+
raw = await request.body()
|
|
172
|
+
try:
|
|
173
|
+
args = json.loads(raw) if raw.strip() else {}
|
|
174
|
+
except ValueError:
|
|
175
|
+
return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
|
|
176
|
+
if not isinstance(args, dict):
|
|
177
|
+
return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
|
|
178
|
+
spec = next((s for s in tools() if s.name == name), None)
|
|
179
|
+
if spec is None:
|
|
180
|
+
return _error(ErrorCode.UNKNOWN_TOOL, f"No tool '{name}'. GET {PREFIX}/tools lists them.")
|
|
181
|
+
return await call(spec, args)
|
|
182
|
+
|
|
183
|
+
for spec in sorted(tools(runner.read_only), key=_route_order):
|
|
184
|
+
method, path = (spec.route or "").split(" ", 1)
|
|
185
|
+
api.add_api_route(
|
|
186
|
+
PREFIX + path,
|
|
187
|
+
endpoint(spec),
|
|
188
|
+
methods=[method],
|
|
189
|
+
name=spec.name,
|
|
190
|
+
operation_id=spec.name,
|
|
191
|
+
summary=spec.title,
|
|
192
|
+
description=description(spec),
|
|
193
|
+
tags=[_tag(path)],
|
|
194
|
+
openapi_extra=_openapi(spec, method, path),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def asgi(
|
|
198
|
+
scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]
|
|
199
|
+
) -> None:
|
|
200
|
+
if scope["type"] == "http" and token:
|
|
201
|
+
sent = dict(scope.get("headers") or []).get(b"authorization", b"").decode()
|
|
202
|
+
if not hmac.compare_digest(sent, f"Bearer {token}"):
|
|
203
|
+
await _error(ErrorCode.AUTH_REQUIRED, f"Send Authorization: Bearer <{app.ENV}_MCP_TOKEN>.")(
|
|
204
|
+
scope, receive, send
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
if scope["type"] == "http" and (scope["path"] == "/mcp" or scope["path"].startswith("/mcp/")):
|
|
208
|
+
await manager.handle_request(scope, receive, send)
|
|
209
|
+
return
|
|
210
|
+
await api(scope, receive, send)
|
|
211
|
+
|
|
212
|
+
return asgi
|
booksy_api_mcp/app.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Everything the server knows about Booksy outside `booksy/`: names, domains and where the session lives.
|
|
2
|
+
|
|
3
|
+
The rest of the package reads these values and never names the app itself.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
NAME = "Booksy"
|
|
9
|
+
SLUG = "booksy"
|
|
10
|
+
# The command and the prefix of every environment variable (BOOKSY_MCP_HOME, BOOKSY_MCP_TOKEN ...).
|
|
11
|
+
CLI = "booksy-mcp"
|
|
12
|
+
ENV = "BOOKSY"
|
|
13
|
+
# Requests may reach only these hosts and their subdomains. Their cookies are read from Chrome.
|
|
14
|
+
DOMAINS = ("booksy.com",)
|
|
15
|
+
# Cookies present only while signed in. Booksy has none that carries the session, so a token cannot be pasted.
|
|
16
|
+
SESSION_COOKIES: tuple[str, ...] = ()
|
|
17
|
+
COOKIE_DOMAIN = ".booksy.com"
|
|
18
|
+
# localStorage read from Chrome at `connect`, by origin. The session is the `_<country>_identity` key of the
|
|
19
|
+
# web-gateway.booksy.com frame the panel embeds; booksy.com's `app.country` says which country the panel has open.
|
|
20
|
+
STORAGE_ORIGINS = ("https://web-gateway.booksy.com", "https://booksy.com")
|
|
21
|
+
# Present only while signed in: origin -> key pattern (regex).
|
|
22
|
+
SESSION_STORAGE = {"https://web-gateway.booksy.com": r"^_[a-z]{2}_identity$"}
|
|
23
|
+
LOGIN_URL = "https://booksy.com/pro/en-us/onboarding/Login"
|
|
24
|
+
# A final URL with one of these means the app signed the session out.
|
|
25
|
+
LOGGED_OUT_MARKERS: tuple[str, ...] = ()
|
|
26
|
+
# Text in a 403 body that means "sign in again": Booksy never answers 401, it names the token field that failed.
|
|
27
|
+
RELOGIN_MARKERS = ('"field":"auth_header_token"', '"field":"access_token"')
|
|
28
|
+
# URL path patterns of the account itself (sign-in, password, deletion): only GET may reach them.
|
|
29
|
+
ACCOUNT_PATHS = (
|
|
30
|
+
r"/business_api/account(/|$)",
|
|
31
|
+
r"/business_api/me/?$",
|
|
32
|
+
r"/business_api/me/(businesses/\d+/)?account_deletion",
|
|
33
|
+
r"/utils/(password_check|phone_check)",
|
|
34
|
+
)
|
|
35
|
+
# REST routes live under this path, the same as on cotlet.dev.
|
|
36
|
+
API_PREFIX = "/api/v1/booksy"
|
|
37
|
+
# Prepended to the MCP server's instructions: where an agent starts.
|
|
38
|
+
AGENT_START = (
|
|
39
|
+
"Start with list_services and list_staff: their ids are what the calendar tools take. "
|
|
40
|
+
"Times are local to the business."
|
|
41
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Who is signed in: Booksy's own answer for the account (business_api/me) and its businesses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from booksy_api_mcp.booksy.client import headers, session
|
|
8
|
+
from booksy_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from booksy_api_mcp.core.context import ToolContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def whoami(ctx: ToolContext) -> str | None:
|
|
15
|
+
"""None when the gateway holds no identity or Booksy says signed out; any other failure raises."""
|
|
16
|
+
s = session(ctx)
|
|
17
|
+
if s is None:
|
|
18
|
+
return None
|
|
19
|
+
sent = headers(ctx, s)
|
|
20
|
+
try:
|
|
21
|
+
me = await ctx.call("GET", f"{s.api}/business_api/me/", headers=sent)
|
|
22
|
+
if me.status != 200:
|
|
23
|
+
raise ToolError(f"Booksy answered {me.status} to who am I.", ErrorCode.TARGET_ERROR)
|
|
24
|
+
owned = await ctx.call("GET", f"{s.api}/business_api/me/businesses/", headers=sent)
|
|
25
|
+
except AuthExpiredError:
|
|
26
|
+
return None
|
|
27
|
+
account = me.json().get("account") or {}
|
|
28
|
+
name = f"{account.get('first_name') or ''} {account.get('last_name') or ''}".strip() or account.get("email")
|
|
29
|
+
businesses = (owned.json().get("businesses") or []) if owned.status == 200 else []
|
|
30
|
+
# The identity's businessId is the one the panel has open; name it first.
|
|
31
|
+
businesses.sort(key=lambda b: b.get("id") != s.business_id)
|
|
32
|
+
names = ", ".join(b["name"] for b in businesses if b.get("name"))
|
|
33
|
+
return f"{name} ({names})" if name and names else name or None
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Calendar, appointments, open slots and time off: requests and shapes shared by those tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
from booksy_api_mcp.booksy.client import headers, session
|
|
10
|
+
from booksy_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from booksy_api_mcp.core.context import HttpResponse, ToolContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def request(
|
|
17
|
+
ctx: ToolContext,
|
|
18
|
+
method: str,
|
|
19
|
+
path: str,
|
|
20
|
+
*,
|
|
21
|
+
params: dict[str, Any] | None = None,
|
|
22
|
+
body: Any = None,
|
|
23
|
+
business: bool = True,
|
|
24
|
+
) -> HttpResponse:
|
|
25
|
+
"""One API call; `path` is under the open business (`~`) unless `business` is False."""
|
|
26
|
+
s = session(ctx)
|
|
27
|
+
if s is None or s.business_id is None:
|
|
28
|
+
raise AuthExpiredError("Booksy is signed out.")
|
|
29
|
+
base = f"{s.api}/business_api/me/businesses/{s.business_id}" if business else f"{s.api}/business_api/me"
|
|
30
|
+
kwargs: dict[str, Any] = {"headers": headers(ctx, s)}
|
|
31
|
+
if params:
|
|
32
|
+
kwargs["params"] = params
|
|
33
|
+
if body is not None:
|
|
34
|
+
kwargs["json"] = body
|
|
35
|
+
return await ctx.call(method, f"{base}{path}", **kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def problem(response: HttpResponse) -> str | None:
|
|
39
|
+
"""Booksy's own words for a refused request, or None when it went through."""
|
|
40
|
+
if response.status < 400:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
data = response.json()
|
|
44
|
+
except ToolError:
|
|
45
|
+
return f"Booksy refused the request ({response.status})."
|
|
46
|
+
if isinstance(data, dict):
|
|
47
|
+
errors = data.get("errors")
|
|
48
|
+
if isinstance(errors, list) and errors:
|
|
49
|
+
texts = [_error_text(e) for e in errors if isinstance(e, dict)]
|
|
50
|
+
if any(texts):
|
|
51
|
+
return " ".join(t for t in texts if t)
|
|
52
|
+
if data.get("detail"):
|
|
53
|
+
return str(data["detail"])
|
|
54
|
+
for field, value in data.items():
|
|
55
|
+
if isinstance(value, list) and value and isinstance(value[0], str):
|
|
56
|
+
return f"{field}: {value[0]}"
|
|
57
|
+
return f"Booksy refused the request ({response.status})."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _error_text(error: dict[str, Any]) -> str:
|
|
61
|
+
text = str(error.get("description") or error.get("code") or "")
|
|
62
|
+
field = error.get("field")
|
|
63
|
+
return f"{field}: {text}" if field and field != "non_field_errors" and text else text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def ok_json(ctx: ToolContext, method: str, path: str, **kwargs: Any) -> Any:
|
|
67
|
+
response = await request(ctx, method, path, **kwargs)
|
|
68
|
+
if response.status == 404:
|
|
69
|
+
raise ToolError("Booksy has no such record.", ErrorCode.TARGET_ERROR)
|
|
70
|
+
if (text := problem(response)) is not None:
|
|
71
|
+
raise ToolError(text, ErrorCode.TARGET_ERROR)
|
|
72
|
+
return response.json() if response.text.strip() else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def business_zone(ctx: ToolContext) -> ZoneInfo:
|
|
76
|
+
"""The business's time zone; times in the API are local to it."""
|
|
77
|
+
cached = await ctx.cache_get(f"tz:{_business_key(ctx)}")
|
|
78
|
+
if cached:
|
|
79
|
+
return ZoneInfo(cached)
|
|
80
|
+
data = await ok_json(ctx, "GET", "")
|
|
81
|
+
business = data.get("business") or data
|
|
82
|
+
name = ((business.get("location") or {}).get("time_zone_name")) or business.get("timezone_name") or "UTC"
|
|
83
|
+
await ctx.cache_set(f"tz:{_business_key(ctx)}", name, 3600)
|
|
84
|
+
return ZoneInfo(name)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _business_key(ctx: ToolContext) -> str:
|
|
88
|
+
s = session(ctx)
|
|
89
|
+
return f"{s.country}:{s.business_id}" if s else "none"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def iso(local: str | None, zone: ZoneInfo) -> str | None:
|
|
93
|
+
"""`YYYY-MM-DDTHH:MM` local to the business as ISO 8601 with its offset."""
|
|
94
|
+
if not local:
|
|
95
|
+
return None
|
|
96
|
+
return datetime.fromisoformat(local).replace(tzinfo=zone).isoformat()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def local(value: str, zone: ZoneInfo) -> datetime:
|
|
100
|
+
"""An ISO 8601 input as naive business-local time; a time without a zone is taken as local."""
|
|
101
|
+
try:
|
|
102
|
+
moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
raise ToolError(f"Not an ISO 8601 date-time: {value}.", ErrorCode.INVALID_INPUT) from exc
|
|
105
|
+
if moment.tzinfo is not None:
|
|
106
|
+
moment = moment.astimezone(zone).replace(tzinfo=None)
|
|
107
|
+
return moment
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def stamp(moment: datetime) -> str:
|
|
111
|
+
return moment.strftime("%Y-%m-%dT%H:%M")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def day(value: str) -> date:
|
|
115
|
+
try:
|
|
116
|
+
return date.fromisoformat(value)
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
raise ToolError(f"Not a date (YYYY-MM-DD): {value}.", ErrorCode.INVALID_INPUT) from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def days(start: date, end: date) -> list[date]:
|
|
122
|
+
return [start + timedelta(days=n) for n in range((end - start).days + 1)]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def int_id(value: Any, what: str) -> int:
|
|
126
|
+
try:
|
|
127
|
+
return int(str(value).strip())
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise ToolError(f"{what} must be a number from Booksy.", ErrorCode.INVALID_INPUT) from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def service_variant(ctx: ToolContext, service_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
133
|
+
"""The service and its first variant (duration, price, staffers); a variant id also finds its service."""
|
|
134
|
+
wanted = int_id(service_id, "service_id")
|
|
135
|
+
for service in (await ok_json(ctx, "GET", "/services")).get("services") or []:
|
|
136
|
+
variants = service.get("variants") or []
|
|
137
|
+
if service.get("id") == wanted and variants:
|
|
138
|
+
return service, variants[0]
|
|
139
|
+
for variant in variants:
|
|
140
|
+
if variant.get("id") == wanted:
|
|
141
|
+
return service, variant
|
|
142
|
+
raise ToolError("No service with this ID in Booksy. Take one from list_services.", ErrorCode.INVALID_INPUT)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Booksy's appointment status letters, as the calendar legend names them.
|
|
146
|
+
STATUSES = {
|
|
147
|
+
"A": "confirmed",
|
|
148
|
+
"W": "pending",
|
|
149
|
+
"C": "cancelled",
|
|
150
|
+
"D": "declined",
|
|
151
|
+
"N": "no_show",
|
|
152
|
+
"F": "finished",
|
|
153
|
+
"P": "payment_pending",
|
|
154
|
+
"V": "pending_payment",
|
|
155
|
+
"R": "rejected",
|
|
156
|
+
"M": "modified",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def status_name(code: Any) -> str:
|
|
161
|
+
return STATUSES.get(str(code), str(code or "unknown").lower())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def appointment_view(appointment: dict[str, Any], customer: dict[str, Any] | None, zone: ZoneInfo) -> dict[str, Any]:
|
|
165
|
+
"""One appointment as the tools return it."""
|
|
166
|
+
subs = appointment.get("subbookings") or []
|
|
167
|
+
merged = (customer or {}).get("merged_data") or {}
|
|
168
|
+
client = None
|
|
169
|
+
if merged or (appointment.get("customer") or {}).get("id"):
|
|
170
|
+
client = {
|
|
171
|
+
"client_id": str(merged.get("id") or (appointment.get("customer") or {}).get("id") or ""),
|
|
172
|
+
"name": merged.get("full_name") or (appointment.get("customer") or {}).get("name") or "",
|
|
173
|
+
}
|
|
174
|
+
services = [
|
|
175
|
+
{
|
|
176
|
+
"service_id": str((sub.get("service") or {}).get("id") or ""),
|
|
177
|
+
"name": (sub.get("service") or {}).get("name") or "",
|
|
178
|
+
"price": ((sub.get("service") or {}).get("variant") or {}).get("price"),
|
|
179
|
+
"duration_minutes": ((sub.get("service") or {}).get("variant") or {}).get("duration"),
|
|
180
|
+
"staff_id": str(sub.get("staffer_id") or ""),
|
|
181
|
+
"staff": (sub.get("staffer") or {}).get("name") or "",
|
|
182
|
+
"start": iso(sub.get("booked_from"), zone),
|
|
183
|
+
"end": iso(sub.get("booked_till"), zone),
|
|
184
|
+
}
|
|
185
|
+
for sub in subs
|
|
186
|
+
]
|
|
187
|
+
actions = appointment.get("actions") or {}
|
|
188
|
+
return {
|
|
189
|
+
"appointment_id": str(appointment.get("appointment_uid") or ""),
|
|
190
|
+
"status": status_name(appointment.get("status")),
|
|
191
|
+
"client": client,
|
|
192
|
+
"services": services,
|
|
193
|
+
"staff": services[0]["staff"] if services else "",
|
|
194
|
+
"start": iso(appointment.get("booked_from"), zone),
|
|
195
|
+
"end": iso(appointment.get("booked_till"), zone),
|
|
196
|
+
"total": appointment.get("total_value"),
|
|
197
|
+
"total_text": appointment.get("total"),
|
|
198
|
+
"note": appointment.get("business_secret_note") or "",
|
|
199
|
+
"client_note": appointment.get("business_note") or "",
|
|
200
|
+
"can": sorted(name for name, allowed in actions.items() if allowed),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def read_appointment(ctx: ToolContext, appointment_id: str) -> dict[str, Any]:
|
|
205
|
+
"""The raw answer of GET appointments/{uid}/: `appointment` and `customer`."""
|
|
206
|
+
uid = int_id(appointment_id, "appointment_id")
|
|
207
|
+
return await ok_json(ctx, "GET", f"/appointments/{uid}/")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
MAX_DAYS = 31
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def calendar(ctx: ToolContext, start: date, end: date) -> dict[str, Any]:
|
|
214
|
+
"""The calendar grid for a day range: every resource, booking, block and time off."""
|
|
215
|
+
if end < start:
|
|
216
|
+
raise ToolError("The last day is before the first day.", ErrorCode.INVALID_INPUT)
|
|
217
|
+
if (end - start).days >= MAX_DAYS:
|
|
218
|
+
raise ToolError(f"Ask for at most {MAX_DAYS} days at a time.", ErrorCode.INVALID_INPUT)
|
|
219
|
+
params = {
|
|
220
|
+
"start_date": start.isoformat(),
|
|
221
|
+
"end_date": end.isoformat(),
|
|
222
|
+
"include_unconfirmed": "true",
|
|
223
|
+
"version": "3",
|
|
224
|
+
"resources_per_page": "500",
|
|
225
|
+
}
|
|
226
|
+
return await ok_json(ctx, "GET", "/calendar", params=params)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def staff_names(grid: dict[str, Any]) -> dict[int, str]:
|
|
230
|
+
return {r["id"]: r.get("name") or "" for r in grid.get("resources") or [] if isinstance(r.get("id"), int)}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Time off is Shifts' whole-day absence, a block is the calendar's time reservation; one id names both.
|
|
234
|
+
TIME_OFF_REASONS = ("vacation", "sick_day", "personal_day", "training", "early", "late", "no_show", "other")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def time_off_id(kind: str, raw: Any) -> str:
|
|
238
|
+
return f"{'off' if kind == 'time_off' else 'block'}-{raw}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_time_off_id(value: str) -> tuple[str, int]:
|
|
242
|
+
kind, _, raw = value.strip().partition("-")
|
|
243
|
+
if kind not in ("off", "block") or not raw.isdigit():
|
|
244
|
+
raise ToolError(
|
|
245
|
+
"time_off_id comes from add_time_off or list_time_off (off-… or block-…).", ErrorCode.INVALID_INPUT
|
|
246
|
+
)
|
|
247
|
+
return ("time_off" if kind == "off" else "block"), int(raw)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Booksy Biz's session and API, recorded 21.10.2026.
|
|
2
|
+
|
|
3
|
+
The session is not a cookie. The panel on booksy.com keeps its identity in the `web-gateway.booksy.com` iframe:
|
|
4
|
+
localStorage key `_<country>_identity` (`_pl_identity`), JSON `{accessToken, loginType, profileType, profile}`.
|
|
5
|
+
The gateway script builds the key as `_${country}_identity` and deletes it on sign-out (CLEAR_IDENTITY).
|
|
6
|
+
booksy.com's own storage has no token. Every API call carries the token as `X-Access-Token`, the public
|
|
7
|
+
`X-Api-Key` of the web panel and `X-Fingerprint` (the `_bf` cookie on .booksy.com).
|
|
8
|
+
|
|
9
|
+
The API host is the country: `https://<country>.booksy.com/core/v2/...` (the bundle's `core` subdomain is
|
|
10
|
+
`{country}`), so the country comes from the identity key and a US account goes to us.booksy.com.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from booksy_api_mcp.core.context import ToolContext
|
|
22
|
+
|
|
23
|
+
WEB = "https://booksy.com"
|
|
24
|
+
GATEWAY = "https://web-gateway.booksy.com"
|
|
25
|
+
# Public key of the web panel (frontdesk), the same in every country.
|
|
26
|
+
API_KEY = "frontdesk-76661e2b-25f0-49b4-b33a-9d78957a58e3"
|
|
27
|
+
_IDENTITY_KEY = re.compile(r"^_([a-z]{2})_identity$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Session:
|
|
32
|
+
country: str
|
|
33
|
+
token: str
|
|
34
|
+
profile: dict[str, Any]
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def api(self) -> str:
|
|
38
|
+
return f"https://{self.country}.booksy.com/core/v2"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def business_id(self) -> int | None:
|
|
42
|
+
return self.profile.get("businessId")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _identity(raw: str | None) -> dict[str, Any] | None:
|
|
46
|
+
if not raw:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
value = json.loads(raw)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
if not isinstance(value, dict) or not isinstance(value.get("accessToken"), str):
|
|
53
|
+
return None
|
|
54
|
+
return value if value["accessToken"] and isinstance(value.get("profile"), dict) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def session_of(local_storage: dict[str, dict[str, str]]) -> Session | None:
|
|
58
|
+
"""The signed-in identity in a jar's or a tab's `local_storage`; None when the gateway holds none.
|
|
59
|
+
|
|
60
|
+
One browser can hold identities of several countries; the panel's last country (`app.country` on
|
|
61
|
+
booksy.com) wins, else the first one found.
|
|
62
|
+
"""
|
|
63
|
+
stored = local_storage.get(GATEWAY) or {}
|
|
64
|
+
found: dict[str, dict[str, Any]] = {}
|
|
65
|
+
for key, raw in stored.items():
|
|
66
|
+
match = _IDENTITY_KEY.match(key)
|
|
67
|
+
if match and (identity := _identity(raw)) is not None:
|
|
68
|
+
found[match.group(1)] = identity
|
|
69
|
+
if not found:
|
|
70
|
+
return None
|
|
71
|
+
last = (local_storage.get(WEB) or {}).get("app.country", "").strip('"').lower()
|
|
72
|
+
country = last if last in found else sorted(found)[0]
|
|
73
|
+
return Session(country=country, token=found[country]["accessToken"], profile=found[country]["profile"])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def session(ctx: ToolContext) -> Session | None:
|
|
77
|
+
return session_of(ctx.profile.local_storage)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def headers(ctx: ToolContext, s: Session) -> dict[str, str]:
|
|
81
|
+
"""What the panel sends with every API call."""
|
|
82
|
+
found = {
|
|
83
|
+
"x-api-key": API_KEY,
|
|
84
|
+
"x-access-token": s.token,
|
|
85
|
+
"x-app-version": "3.0",
|
|
86
|
+
"accept": "application/json",
|
|
87
|
+
"origin": WEB,
|
|
88
|
+
"referer": f"{WEB}/",
|
|
89
|
+
}
|
|
90
|
+
fingerprint = next((c.value for c in ctx.profile.cookies if c.name == "_bf" and c.value), None)
|
|
91
|
+
if fingerprint:
|
|
92
|
+
found["x-fingerprint"] = fingerprint
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def request_headers(ctx: ToolContext) -> dict[str, str]:
|
|
97
|
+
"""The tools' identity headers for one request; only the public
|
|
98
|
+
api key when the gateway holds no identity, so Booksy answers its signed-out 403."""
|
|
99
|
+
s = session(ctx)
|
|
100
|
+
return headers(ctx, s) if s is not None else {"x-api-key": API_KEY}
|