partiful-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.
- partiful_api_mcp/__init__.py +28 -0
- partiful_api_mcp/api.py +213 -0
- partiful_api_mcp/app.py +37 -0
- partiful_api_mcp/cli.py +192 -0
- partiful_api_mcp/core/__init__.py +0 -0
- partiful_api_mcp/core/account_guard.py +35 -0
- partiful_api_mcp/core/context.py +191 -0
- partiful_api_mcp/core/errors.py +78 -0
- partiful_api_mcp/core/helpers.py +33 -0
- partiful_api_mcp/core/tools.py +106 -0
- partiful_api_mcp/mcp_context.py +126 -0
- partiful_api_mcp/mcp_server.py +67 -0
- partiful_api_mcp/partiful/__init__.py +0 -0
- partiful_api_mcp/partiful/auth.py +22 -0
- partiful_api_mcp/partiful/client.py +118 -0
- partiful_api_mcp/partiful/editing.py +123 -0
- partiful_api_mcp/partiful/events.py +158 -0
- partiful_api_mcp/partiful/firestore.py +135 -0
- partiful_api_mcp/partiful/guests.py +77 -0
- partiful_api_mcp/partiful/tools/__init__.py +0 -0
- partiful_api_mcp/partiful/tools/approve_guests.py +79 -0
- partiful_api_mcp/partiful/tools/cancel_event.py +69 -0
- partiful_api_mcp/partiful/tools/check_in_guests.py +64 -0
- partiful_api_mcp/partiful/tools/create_event.py +96 -0
- partiful_api_mcp/partiful/tools/delete_event.py +49 -0
- partiful_api_mcp/partiful/tools/duplicate_event.py +83 -0
- partiful_api_mcp/partiful/tools/export_guest_list.py +83 -0
- partiful_api_mcp/partiful/tools/get_event.py +60 -0
- partiful_api_mcp/partiful/tools/invite_guests.py +107 -0
- partiful_api_mcp/partiful/tools/list_events.py +81 -0
- partiful_api_mcp/partiful/tools/list_guests.py +81 -0
- partiful_api_mcp/partiful/tools/list_past_guests.py +115 -0
- partiful_api_mcp/partiful/tools/send_text_blast.py +100 -0
- partiful_api_mcp/partiful/tools/update_event.py +50 -0
- partiful_api_mcp/runner.py +117 -0
- partiful_api_mcp/scripting.py +73 -0
- partiful_api_mcp/session.py +275 -0
- partiful_api_mcp-1.0.2.dist-info/METADATA +422 -0
- partiful_api_mcp-1.0.2.dist-info/RECORD +42 -0
- partiful_api_mcp-1.0.2.dist-info/WHEEL +4 -0
- partiful_api_mcp-1.0.2.dist-info/entry_points.txt +3 -0
- partiful_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Partiful's API, recorded on the dev account (21.10.2026).
|
|
2
|
+
|
|
3
|
+
Every call is a Firebase callable: POST `api.partiful.com/<function>` with `{"data": {"params": {...}}}`,
|
|
4
|
+
answered with `{"result": {"data": ...}}`, and the account's Firebase ID token as `Authorization: Bearer`.
|
|
5
|
+
The `_partiful_session` cookie does not authenticate it (401 without the token) and no page on partiful.com
|
|
6
|
+
renders the user on the server, so the cookie only tells the core that the browser has signed in.
|
|
7
|
+
|
|
8
|
+
The ID token lives an hour. The web app keeps the Firebase user, with its refresh token, in IndexedDB
|
|
9
|
+
(`firebaseLocalStorageDb`, key `AUTH_USER_KEY`); `id_token` reads that record from the profile's storage for
|
|
10
|
+
partiful.com and trades the refresh token at securetoken.googleapis.com. Google answers with the same
|
|
11
|
+
refresh token, so nothing has to be written back. Tokens stay in this process's memory, never in Redis.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
from partiful_api_mcp.core.errors import AuthExpiredError, ErrorCode, ToolError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
25
|
+
|
|
26
|
+
WEB = "https://partiful.com"
|
|
27
|
+
API = "https://api.partiful.com"
|
|
28
|
+
# Public web key of Firebase project getpartiful; Google refuses it without a partiful.com referer.
|
|
29
|
+
FIREBASE_KEY = "AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k"
|
|
30
|
+
SECURETOKEN = f"https://securetoken.googleapis.com/v1/token?key={FIREBASE_KEY}"
|
|
31
|
+
AUTH_USER_KEY = f"firebase:authUser:{FIREBASE_KEY}:[DEFAULT]"
|
|
32
|
+
_HEADERS = {"origin": WEB, "referer": f"{WEB}/"}
|
|
33
|
+
# A token this close to its end is refreshed first (seconds).
|
|
34
|
+
_MARGIN_S = 300
|
|
35
|
+
# (browser_id, uid) -> (ID token, expiry as epoch seconds)
|
|
36
|
+
_tokens: dict[tuple[str, str], tuple[str, float]] = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def firebase_user(ctx: ToolContext) -> dict[str, Any] | None:
|
|
40
|
+
"""The signed-in Firebase user from the browser's storage for partiful.com; None when there is none."""
|
|
41
|
+
return auth_user(ctx.profile.local_storage)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def auth_user(local_storage: dict[str, dict[str, str]]) -> dict[str, Any] | None:
|
|
45
|
+
"""The Firebase user in a jar's or a tab's `local_storage`; None without a uid and a refresh token."""
|
|
46
|
+
raw = (local_storage.get(WEB) or {}).get(AUTH_USER_KEY)
|
|
47
|
+
if not raw:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
user = json.loads(raw) if isinstance(raw, str) else raw
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
if not isinstance(user, dict) or not user.get("uid") or not (user.get("stsTokenManager") or {}).get("refreshToken"):
|
|
54
|
+
return None
|
|
55
|
+
return user
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def id_token(ctx: ToolContext) -> tuple[str, str]:
|
|
59
|
+
"""(uid, ID token) of the account. `AuthExpiredError` when the browser holds no usable Firebase session."""
|
|
60
|
+
user = firebase_user(ctx)
|
|
61
|
+
if user is None:
|
|
62
|
+
raise AuthExpiredError("The app signed this account out. Reconnect it in the console.")
|
|
63
|
+
uid, tokens = user["uid"], user["stsTokenManager"]
|
|
64
|
+
key = (ctx.profile.browser_id, uid)
|
|
65
|
+
now = time.time()
|
|
66
|
+
cached = _tokens.get(key)
|
|
67
|
+
if cached and cached[1] - now > _MARGIN_S:
|
|
68
|
+
return uid, cached[0]
|
|
69
|
+
stored, expires_ms = tokens.get("accessToken"), tokens.get("expirationTime") or 0
|
|
70
|
+
if stored and expires_ms / 1000 - now > _MARGIN_S:
|
|
71
|
+
_tokens[key] = (stored, expires_ms / 1000)
|
|
72
|
+
return uid, stored
|
|
73
|
+
response = await ctx.call(
|
|
74
|
+
"POST",
|
|
75
|
+
SECURETOKEN,
|
|
76
|
+
data=urlencode({"grant_type": "refresh_token", "refresh_token": tokens["refreshToken"]}),
|
|
77
|
+
headers={**_HEADERS, "content-type": "application/x-www-form-urlencoded"},
|
|
78
|
+
)
|
|
79
|
+
answer = response.json()
|
|
80
|
+
if response.status == 400:
|
|
81
|
+
# INVALID_REFRESH_TOKEN, TOKEN_EXPIRED, USER_DISABLED, USER_NOT_FOUND: the session is over.
|
|
82
|
+
raise AuthExpiredError("The app signed this account out. Reconnect it in the console.")
|
|
83
|
+
if response.status != 200 or not answer.get("id_token"):
|
|
84
|
+
raise ToolError("Partiful's sign-in service did not renew the session.", ErrorCode.TARGET_ERROR)
|
|
85
|
+
token = answer["id_token"]
|
|
86
|
+
_tokens[key] = (token, now + int(answer.get("expires_in") or 3600))
|
|
87
|
+
return uid, token
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def call(
|
|
91
|
+
ctx: ToolContext,
|
|
92
|
+
function: str,
|
|
93
|
+
params: dict[str, Any] | None = None,
|
|
94
|
+
paging: dict[str, Any] | None = None,
|
|
95
|
+
*,
|
|
96
|
+
whole: bool = False,
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""One callable on api.partiful.com; returns `result.data` (`result` with `whole`, for its `paging`).
|
|
99
|
+
|
|
100
|
+
`paging` (`cursor`, `maxResults`) goes next to `params`, as the web app sends it. A 401 raises
|
|
101
|
+
`AuthExpiredError` in `ctx.call`.
|
|
102
|
+
"""
|
|
103
|
+
_, token = await id_token(ctx)
|
|
104
|
+
data: dict[str, Any] = {"params": params or {}}
|
|
105
|
+
if paging:
|
|
106
|
+
data["paging"] = paging
|
|
107
|
+
response = await ctx.call(
|
|
108
|
+
"POST",
|
|
109
|
+
f"{API}/{function}",
|
|
110
|
+
json={"data": data},
|
|
111
|
+
headers={**_HEADERS, "authorization": f"Bearer {token}"},
|
|
112
|
+
)
|
|
113
|
+
answer = response.json()
|
|
114
|
+
if response.status != 200 or "result" not in answer:
|
|
115
|
+
message = (answer.get("error") or {}).get("message") if isinstance(answer, dict) else None
|
|
116
|
+
raise ToolError(f"Partiful refused {function}: {message or response.status}.", ErrorCode.TARGET_ERROR)
|
|
117
|
+
result = answer["result"] or {}
|
|
118
|
+
return result if whole else result.get("data")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Event edits as the web app makes them (recorded 21.10.2026): a Firestore update of `events/{eventId}` with
|
|
2
|
+
`updatedBy`, and the location through the callable `setEventLocation`, which keeps it out of that update.
|
|
3
|
+
|
|
4
|
+
Guest Approval and a guest limit exclude each other: turning approval on clears `maxCapacity` and the
|
|
5
|
+
waitlist, and the first guest limit turns the waitlist on, as the settings panel does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from partiful_api_mcp.core.errors import ErrorCode, ToolError
|
|
15
|
+
from partiful_api_mcp.partiful import firestore
|
|
16
|
+
from partiful_api_mcp.partiful.client import call, id_token
|
|
17
|
+
from partiful_api_mcp.partiful.events import moment, zone
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
21
|
+
|
|
22
|
+
# The inputs create_event and update_event share; create_event requires title and start_at.
|
|
23
|
+
FIELDS: dict[str, Any] = {
|
|
24
|
+
"title": {"type": "string", "minLength": 1, "maxLength": 200, "description": "Event title."},
|
|
25
|
+
"start_at": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Start, ISO 8601. Without an offset it is read in the event's time zone.",
|
|
28
|
+
},
|
|
29
|
+
"end_at": {"type": "string", "description": "End, ISO 8601. Optional; must be after start_at."},
|
|
30
|
+
"timezone": {"type": "string", "description": "IANA zone the event is shown in, e.g. America/New_York."},
|
|
31
|
+
"location": {"type": "string", "maxLength": 300, "description": "Address, place name or link."},
|
|
32
|
+
"description": {"type": "string", "maxLength": 5000, "description": "Event details shown on the page."},
|
|
33
|
+
"rsvp_deadline": {"type": "string", "description": "Last moment guests can RSVP, ISO 8601."},
|
|
34
|
+
"max_guests": {
|
|
35
|
+
"type": ["integer", "null"],
|
|
36
|
+
"minimum": 1,
|
|
37
|
+
"description": "Guest limit; RSVPs over it go to the waitlist. null removes the limit.",
|
|
38
|
+
},
|
|
39
|
+
"guest_approval": {
|
|
40
|
+
"type": "boolean",
|
|
41
|
+
"description": "Guests ask to join and you approve them (Guest Approval). Clears max_guests.",
|
|
42
|
+
},
|
|
43
|
+
"questions": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"items": {"type": "string", "minLength": 1, "maxLength": 200},
|
|
46
|
+
"maxItems": 10,
|
|
47
|
+
"description": "Guest Questionnaire, short-answer questions asked at RSVP. An empty list turns it off.",
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def apply(ctx: ToolContext, event_id: str, old: dict[str, Any], args: dict[str, Any]) -> list[str]:
|
|
53
|
+
"""Writes the given inputs to the event `old` (getEventInfo's event); returns the input names changed."""
|
|
54
|
+
uid, _ = await id_token(ctx)
|
|
55
|
+
tz_name = args.get("timezone") or old.get("timezone") or ctx.profile.timezone
|
|
56
|
+
tz = zone(tz_name)
|
|
57
|
+
changes: dict[str, Any] = {}
|
|
58
|
+
remove: list[str] = []
|
|
59
|
+
append: dict[str, list[Any]] = {}
|
|
60
|
+
if "timezone" in args:
|
|
61
|
+
changes["timezone"] = tz.key
|
|
62
|
+
for name, field in (("title", "title"), ("description", "description")):
|
|
63
|
+
if name in args:
|
|
64
|
+
changes[field] = args[name].strip() if name == "title" else args[name]
|
|
65
|
+
start = moment(args["start_at"], tz, "start_at") if args.get("start_at") else _stored(old.get("startDate"))
|
|
66
|
+
end = moment(args["end_at"], tz, "end_at") if args.get("end_at") else _stored(old.get("endDate"))
|
|
67
|
+
if args.get("start_at"):
|
|
68
|
+
changes["startDate"] = start
|
|
69
|
+
if args.get("end_at"):
|
|
70
|
+
changes["endDate"] = end
|
|
71
|
+
if start and end and end <= start and (args.get("start_at") or args.get("end_at")):
|
|
72
|
+
raise ToolError("end_at must be after start_at.", ErrorCode.INVALID_INPUT)
|
|
73
|
+
if args.get("rsvp_deadline"):
|
|
74
|
+
changes["rsvpDeadline"] = moment(args["rsvp_deadline"], tz, "rsvp_deadline")
|
|
75
|
+
approval = args.get("guest_approval")
|
|
76
|
+
if approval is True and args.get("max_guests") is not None:
|
|
77
|
+
raise ToolError("Guest Approval events have no guest limit: give one of the two.", ErrorCode.INVALID_INPUT)
|
|
78
|
+
if approval is not None:
|
|
79
|
+
changes["guestAction"] = "APPLY" if approval else "RSVP"
|
|
80
|
+
if approval:
|
|
81
|
+
remove.append("maxCapacity")
|
|
82
|
+
changes["enableWaitlist"] = False
|
|
83
|
+
if "max_guests" in args:
|
|
84
|
+
if old.get("guestAction") == "APPLY" and approval is not False and args["max_guests"] is not None:
|
|
85
|
+
raise ToolError("This event uses Guest Approval, which has no guest limit.", ErrorCode.INVALID_INPUT)
|
|
86
|
+
if args["max_guests"] is None:
|
|
87
|
+
remove.append("maxCapacity")
|
|
88
|
+
changes["enableWaitlist"] = False
|
|
89
|
+
else:
|
|
90
|
+
changes["maxCapacity"] = args["max_guests"]
|
|
91
|
+
if old.get("maxCapacity") is None:
|
|
92
|
+
changes["enableWaitlist"] = True
|
|
93
|
+
if "questions" in args:
|
|
94
|
+
if args["questions"]:
|
|
95
|
+
base = int(time.time() * 1000)
|
|
96
|
+
version = {
|
|
97
|
+
"questions": [
|
|
98
|
+
{"id": str(base + i), "text": text.strip(), "required": False, "type": "short_answer"}
|
|
99
|
+
for i, text in enumerate(args["questions"])
|
|
100
|
+
],
|
|
101
|
+
"createdAt": datetime.now(UTC),
|
|
102
|
+
"createdBy": firestore.Ref(f"users/{uid}"),
|
|
103
|
+
}
|
|
104
|
+
append["questionnaireVersions"] = [version]
|
|
105
|
+
changes["questionnaireEnabled"] = True
|
|
106
|
+
else:
|
|
107
|
+
changes["questionnaireEnabled"] = False
|
|
108
|
+
if changes or remove or append:
|
|
109
|
+
changes["updatedBy"] = firestore.Ref(f"users/{uid}")
|
|
110
|
+
await firestore.update(ctx, f"events/{event_id}", changes, tuple(remove), append)
|
|
111
|
+
if "location" in args:
|
|
112
|
+
await call(
|
|
113
|
+
ctx,
|
|
114
|
+
"setEventLocation",
|
|
115
|
+
{"eventId": event_id, "locationInfo": {"type": "freeform", "value": args["location"].strip()}},
|
|
116
|
+
)
|
|
117
|
+
return [name for name in FIELDS if name in args]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _stored(value: Any) -> datetime | None:
|
|
121
|
+
if not isinstance(value, str) or not value[:1].isdigit():
|
|
122
|
+
return None
|
|
123
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Events and guests as the tools return them (recorded 21.10.2026, MAP.md).
|
|
2
|
+
|
|
3
|
+
Partiful keeps start, end and RSVP deadline in UTC with the event's IANA zone next to them; a cloned event
|
|
4
|
+
has `startDate: "TBD"` until it gets a date. Guest statuses are Partiful's own, mapped to the catalog's
|
|
5
|
+
names below.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
13
|
+
|
|
14
|
+
from partiful_api_mcp.core.errors import ErrorCode, ToolError
|
|
15
|
+
from partiful_api_mcp.partiful.client import WEB, call, id_token
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
19
|
+
|
|
20
|
+
# Catalog status -> Partiful's guest statuses (getEventInfo `guestStatusCounts`, guest `status`).
|
|
21
|
+
STATUSES: dict[str, tuple[str, ...]] = {
|
|
22
|
+
"going": ("GOING", "APPROVED"),
|
|
23
|
+
"maybe": ("MAYBE",),
|
|
24
|
+
"cant_go": ("DECLINED",),
|
|
25
|
+
"invited": ("READY_TO_SEND", "SENDING", "SENT", "SEND_ERROR", "DELIVERY_ERROR"),
|
|
26
|
+
"waitlist": ("WAITLIST", "WAITLISTED_FOR_APPROVAL"),
|
|
27
|
+
"pending": ("PENDING_APPROVAL",),
|
|
28
|
+
}
|
|
29
|
+
# The rest, shown in list_guests but not counted in `guests`.
|
|
30
|
+
_OTHER = {
|
|
31
|
+
"INTERESTED": "interested",
|
|
32
|
+
"WITHDRAWN": "withdrawn",
|
|
33
|
+
"REJECTED": "rejected",
|
|
34
|
+
"RESPONDED_TO_FIND_A_TIME": "responded_to_poll",
|
|
35
|
+
}
|
|
36
|
+
STATUS_NAMES = list(STATUSES)
|
|
37
|
+
|
|
38
|
+
EVENT_ID: dict[str, Any] = {"type": "string", "description": "event_id from list_events."}
|
|
39
|
+
GUEST_IDS: dict[str, Any] = {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"items": {"type": "string"},
|
|
42
|
+
"minItems": 1,
|
|
43
|
+
"maxItems": 500,
|
|
44
|
+
"description": "guest_id values from list_guests.",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def status_name(status: str | None) -> str:
|
|
49
|
+
"""Partiful's guest status as the catalog name."""
|
|
50
|
+
for name, values in STATUSES.items():
|
|
51
|
+
if status in values:
|
|
52
|
+
return name
|
|
53
|
+
return _OTHER.get(status or "", (status or "unknown").lower())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def counts(item: dict[str, Any]) -> dict[str, int]:
|
|
57
|
+
"""Guests per catalog status from `guestStatusCounts`."""
|
|
58
|
+
raw = item.get("guestStatusCounts") or {}
|
|
59
|
+
return {name: sum(int(raw.get(value) or 0) for value in values) for name, values in STATUSES.items()}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def zone(name: str | None) -> ZoneInfo:
|
|
63
|
+
try:
|
|
64
|
+
return ZoneInfo(name or "UTC")
|
|
65
|
+
except (ZoneInfoNotFoundError, ValueError) as exc:
|
|
66
|
+
raise ToolError("timezone must be an IANA name such as America/New_York.", ErrorCode.INVALID_INPUT) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def moment(value: str, tz: ZoneInfo, field: str) -> datetime:
|
|
70
|
+
"""An input time as an aware datetime; without an offset it is read in the event's zone."""
|
|
71
|
+
try:
|
|
72
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise ToolError(f"{field} must be ISO 8601, e.g. 2026-11-14T19:00-05:00.", ErrorCode.INVALID_INPUT) from exc
|
|
75
|
+
return parsed.replace(tzinfo=tz) if parsed.tzinfo is None else parsed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def utc_iso(value: datetime) -> str:
|
|
79
|
+
"""The form createEvent takes: UTC with milliseconds."""
|
|
80
|
+
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def local(value: Any, tz_name: str | None) -> str | None:
|
|
84
|
+
"""A stored UTC time in the event's zone with its offset; None for none or TBD."""
|
|
85
|
+
if not isinstance(value, str) or not value[:1].isdigit():
|
|
86
|
+
return None
|
|
87
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
88
|
+
try:
|
|
89
|
+
tz = ZoneInfo(tz_name or "UTC")
|
|
90
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
91
|
+
tz = ZoneInfo("UTC")
|
|
92
|
+
return parsed.astimezone(tz).isoformat(timespec="seconds")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def url(event_id: str) -> str:
|
|
96
|
+
return f"{WEB}/e/{event_id}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def status(item: dict[str, Any]) -> str:
|
|
100
|
+
return {"PUBLISHED": "active", "CANCELED": "canceled", "UNSAVED": "draft"}.get(item.get("status") or "", "active")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def summary(item: dict[str, Any]) -> dict[str, Any]:
|
|
104
|
+
"""One event in list_events."""
|
|
105
|
+
tz = item.get("timezone")
|
|
106
|
+
return {
|
|
107
|
+
"event_id": item.get("id"),
|
|
108
|
+
"title": item.get("title"),
|
|
109
|
+
"start_at": local(item.get("startDate"), tz),
|
|
110
|
+
"end_at": local(item.get("endDate"), tz),
|
|
111
|
+
"timezone": tz,
|
|
112
|
+
"location": item.get("location"),
|
|
113
|
+
"status": status(item),
|
|
114
|
+
"guests": counts(item),
|
|
115
|
+
"url": url(item.get("id") or ""),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def questions(item: dict[str, Any]) -> list[dict[str, Any]]:
|
|
120
|
+
"""The current Guest Questionnaire: `questionnaire`, else the last of `questionnaireVersions`."""
|
|
121
|
+
current = item.get("questionnaire")
|
|
122
|
+
if not current and item.get("questionnaireVersions") and item.get("questionnaireEnabled", True):
|
|
123
|
+
current = item["questionnaireVersions"][-1]
|
|
124
|
+
return [q for q in (current or {}).get("questions") or [] if isinstance(q, dict)]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def detail(item: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
"""One event in get_event and the write tools' answers."""
|
|
129
|
+
tz = item.get("timezone")
|
|
130
|
+
return {
|
|
131
|
+
**summary(item),
|
|
132
|
+
"description": item.get("description"),
|
|
133
|
+
"rsvp_deadline": local(item.get("rsvpDeadline"), tz),
|
|
134
|
+
"max_guests": item.get("maxCapacity"),
|
|
135
|
+
"waitlist": bool(item.get("enableWaitlist")),
|
|
136
|
+
"guest_approval": item.get("guestAction") == "APPLY",
|
|
137
|
+
"questions": [q.get("text") for q in questions(item)],
|
|
138
|
+
"visibility": item.get("visibility"),
|
|
139
|
+
"short_url": item.get("publicShortUrl"),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def event_info(ctx: ToolContext, event_id: str) -> dict[str, Any]:
|
|
144
|
+
"""getEventInfo's event, with its id."""
|
|
145
|
+
data = await call(ctx, "getEventInfo", {"eventId": event_id})
|
|
146
|
+
item = (data or {}).get("event")
|
|
147
|
+
if not item:
|
|
148
|
+
raise ToolError("Partiful has no such event.", ErrorCode.INVALID_INPUT)
|
|
149
|
+
return {**item, "id": item.get("id") or event_id}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def hosted(ctx: ToolContext, event_id: str) -> dict[str, Any]:
|
|
153
|
+
"""The event, refused unless this account hosts it."""
|
|
154
|
+
uid, _ = await id_token(ctx)
|
|
155
|
+
item = await event_info(ctx, event_id)
|
|
156
|
+
if uid not in (item.get("ownerIds") or []):
|
|
157
|
+
raise ToolError("Only a host of this event can do that.", ErrorCode.APP_PERMISSION_DENIED)
|
|
158
|
+
return item
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Firestore REST on project getpartiful, with the same Firebase ID token as the callables (21.10.2026).
|
|
2
|
+
|
|
3
|
+
The web app reads the host's guest list and writes event edits straight to Firestore; no callable does
|
|
4
|
+
either. Security rules let the host read `events/{eventId}` with its `guests` and update the event.
|
|
5
|
+
Documents come in Firestore's typed form (`{"stringValue": ...}`): `decode` and `encode` convert.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
from urllib.parse import urlencode
|
|
14
|
+
|
|
15
|
+
from partiful_api_mcp.core.errors import ErrorCode, ToolError
|
|
16
|
+
from partiful_api_mcp.partiful.client import WEB, id_token
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
20
|
+
|
|
21
|
+
DATABASE = "projects/getpartiful/databases/(default)"
|
|
22
|
+
DOCUMENTS = f"https://firestore.googleapis.com/v1/{DATABASE}/documents"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Ref:
|
|
27
|
+
"""A document reference to write, as the web app writes `updatedBy: users/{uid}`."""
|
|
28
|
+
|
|
29
|
+
path: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode(value: dict[str, Any]) -> Any:
|
|
33
|
+
"""One typed Firestore value as plain JSON; references become their `collection/id` path."""
|
|
34
|
+
if "mapValue" in value:
|
|
35
|
+
return fields(value["mapValue"])
|
|
36
|
+
if "arrayValue" in value:
|
|
37
|
+
return [decode(item) for item in value["arrayValue"].get("values") or []]
|
|
38
|
+
if "integerValue" in value:
|
|
39
|
+
return int(value["integerValue"])
|
|
40
|
+
if "referenceValue" in value:
|
|
41
|
+
return value["referenceValue"].split("/documents/", 1)[-1]
|
|
42
|
+
if "nullValue" in value:
|
|
43
|
+
return None
|
|
44
|
+
for kind in ("stringValue", "booleanValue", "doubleValue", "timestampValue"):
|
|
45
|
+
if kind in value:
|
|
46
|
+
return value[kind]
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fields(document: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
"""A document's (or map's) `fields` as a plain dict."""
|
|
52
|
+
return {name: decode(value) for name, value in (document.get("fields") or {}).items()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def encode(value: Any) -> dict[str, Any]:
|
|
56
|
+
"""A plain value in Firestore's typed form. datetime goes as a timestamp."""
|
|
57
|
+
if value is None:
|
|
58
|
+
return {"nullValue": None}
|
|
59
|
+
if isinstance(value, Ref):
|
|
60
|
+
return {"referenceValue": f"{DATABASE}/documents/{value.path}"}
|
|
61
|
+
if isinstance(value, bool):
|
|
62
|
+
return {"booleanValue": value}
|
|
63
|
+
if isinstance(value, int):
|
|
64
|
+
return {"integerValue": str(value)}
|
|
65
|
+
if isinstance(value, float):
|
|
66
|
+
return {"doubleValue": value}
|
|
67
|
+
if isinstance(value, datetime):
|
|
68
|
+
return {"timestampValue": value.isoformat()}
|
|
69
|
+
if isinstance(value, dict):
|
|
70
|
+
return {"mapValue": {"fields": {key: encode(item) for key, item in value.items()}}}
|
|
71
|
+
if isinstance(value, list):
|
|
72
|
+
return {"arrayValue": {"values": [encode(item) for item in value]}}
|
|
73
|
+
return {"stringValue": str(value)}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _request(ctx: ToolContext, method: str, url: str, body: Any = None) -> Any:
|
|
77
|
+
_, token = await id_token(ctx)
|
|
78
|
+
response = await ctx.call(
|
|
79
|
+
method,
|
|
80
|
+
url,
|
|
81
|
+
json=body,
|
|
82
|
+
headers={"origin": WEB, "referer": f"{WEB}/", "authorization": f"Bearer {token}"},
|
|
83
|
+
)
|
|
84
|
+
answer = response.json()
|
|
85
|
+
if response.status == 404:
|
|
86
|
+
raise ToolError("Partiful has no such event.", ErrorCode.INVALID_INPUT)
|
|
87
|
+
if response.status == 403:
|
|
88
|
+
raise ToolError("Only a host of this event can do that.", ErrorCode.APP_PERMISSION_DENIED)
|
|
89
|
+
if response.status != 200:
|
|
90
|
+
raise ToolError("Partiful did not answer the request.", ErrorCode.TARGET_ERROR)
|
|
91
|
+
return answer
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def get(ctx: ToolContext, path: str) -> dict[str, Any]:
|
|
95
|
+
"""One document's fields, with `id`."""
|
|
96
|
+
document = await _request(ctx, "GET", f"{DOCUMENTS}/{path}")
|
|
97
|
+
return {"id": document["name"].rsplit("/", 1)[-1], **fields(document)}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def list_page(
|
|
101
|
+
ctx: ToolContext, path: str, size: int, token: str | None
|
|
102
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
103
|
+
"""One page of a collection: documents with `id`, and the next page token."""
|
|
104
|
+
query = {"pageSize": size, **({"pageToken": token} if token else {})}
|
|
105
|
+
answer = await _request(ctx, "GET", f"{DOCUMENTS}/{path}?{urlencode(query)}")
|
|
106
|
+
docs = [{"id": doc["name"].rsplit("/", 1)[-1], **fields(doc)} for doc in answer.get("documents") or []]
|
|
107
|
+
return docs, answer.get("nextPageToken") or None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def update(
|
|
111
|
+
ctx: ToolContext,
|
|
112
|
+
path: str,
|
|
113
|
+
changes: dict[str, Any],
|
|
114
|
+
remove: tuple[str, ...] = (),
|
|
115
|
+
append: dict[str, list[Any]] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Sets top-level fields of an existing document in one `commit`, as the web app's `Write` does.
|
|
118
|
+
|
|
119
|
+
A name in `remove` is in the mask but not in the body, which deletes the field. `append` adds array
|
|
120
|
+
items server-side (`arrayUnion`), so the stored items are never read back and rewritten.
|
|
121
|
+
"""
|
|
122
|
+
write: dict[str, Any] = {
|
|
123
|
+
"update": {
|
|
124
|
+
"name": f"{DATABASE}/documents/{path}",
|
|
125
|
+
"fields": {name: encode(value) for name, value in changes.items()},
|
|
126
|
+
},
|
|
127
|
+
"updateMask": {"fieldPaths": [*changes, *remove]},
|
|
128
|
+
"currentDocument": {"exists": True},
|
|
129
|
+
}
|
|
130
|
+
if append:
|
|
131
|
+
write["updateTransforms"] = [
|
|
132
|
+
{"fieldPath": name, "appendMissingElements": {"values": [encode(item) for item in items]}}
|
|
133
|
+
for name, items in append.items()
|
|
134
|
+
]
|
|
135
|
+
await _request(ctx, "POST", f"{DOCUMENTS}:commit", {"writes": [write]})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The host's guest list: Firestore `events/{eventId}/guests`, one document per guest (recorded 21.10.2026).
|
|
2
|
+
|
|
3
|
+
No callable gives the host guest ids (`getGuests` refuses a host), so list_guests, approve_guests and
|
|
4
|
+
check_in_guests start here. Questionnaire answers sit in `questionnaireResponse.answers`, keyed by
|
|
5
|
+
question id of the version the guest answered.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from partiful_api_mcp.partiful import firestore
|
|
13
|
+
from partiful_api_mcp.partiful.events import local, questions, status_name
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from partiful_api_mcp.core.context import ToolContext
|
|
17
|
+
|
|
18
|
+
# Firestore's page size for the guests collection; an event holds at most about 1,000 invites.
|
|
19
|
+
_PAGE = 300
|
|
20
|
+
_MAX_PAGES = 10
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def all_guests(ctx: ToolContext, event_id: str) -> list[dict[str, Any]]:
|
|
24
|
+
"""Every guest document of the event, oldest RSVP first."""
|
|
25
|
+
out: list[dict[str, Any]] = []
|
|
26
|
+
token: str | None = None
|
|
27
|
+
for _ in range(_MAX_PAGES):
|
|
28
|
+
docs, token = await firestore.list_page(ctx, f"events/{event_id}/guests", _PAGE, token)
|
|
29
|
+
out.extend(docs)
|
|
30
|
+
if not token:
|
|
31
|
+
break
|
|
32
|
+
return sorted(out, key=lambda doc: str(doc.get("rsvpDate") or doc.get("createdAt") or ""))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def checked_in(doc: dict[str, Any]) -> bool:
|
|
36
|
+
"""`checkIn` is `{checkedInBy, date}` while the guest is checked in."""
|
|
37
|
+
value = doc.get("checkIn")
|
|
38
|
+
return bool(value.get("date") or value.get("checkedInBy")) if isinstance(value, dict) else value is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _answer(value: Any) -> str:
|
|
42
|
+
if isinstance(value, dict):
|
|
43
|
+
value = value.get("value", value.get("text", ""))
|
|
44
|
+
if isinstance(value, list):
|
|
45
|
+
return ", ".join(str(item) for item in value)
|
|
46
|
+
return "" if value is None else str(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def guest(doc: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
"""One guest as list_guests returns it."""
|
|
51
|
+
response = doc.get("questionnaireResponse") or {}
|
|
52
|
+
versions = event.get("questionnaireVersions") or []
|
|
53
|
+
version = response.get("questionnaireVersion")
|
|
54
|
+
asked = (
|
|
55
|
+
versions[version].get("questions") or []
|
|
56
|
+
if isinstance(version, int) and 0 <= version < len(versions)
|
|
57
|
+
else questions(event)
|
|
58
|
+
)
|
|
59
|
+
texts = {q.get("id"): q.get("text") for q in asked if isinstance(q, dict)}
|
|
60
|
+
answers = response.get("answers") or {}
|
|
61
|
+
user = doc.get("user")
|
|
62
|
+
return {
|
|
63
|
+
"guest_id": doc["id"],
|
|
64
|
+
"name": doc.get("name"),
|
|
65
|
+
"status": status_name(doc.get("status")),
|
|
66
|
+
"count": int(doc.get("count") or 1),
|
|
67
|
+
"plus_ones": [p.get("name") for p in doc.get("plusOnes") or [] if isinstance(p, dict) and p.get("name")],
|
|
68
|
+
"checked_in": checked_in(doc),
|
|
69
|
+
"rsvp_at": local(doc.get("rsvpDate"), event.get("timezone")),
|
|
70
|
+
"message": doc.get("rsvpMessage") or None,
|
|
71
|
+
"user_id": user.rsplit("/", 1)[-1] if isinstance(user, str) else None,
|
|
72
|
+
"answers": [
|
|
73
|
+
{"question": texts.get(key, key), "answer": _answer(value)}
|
|
74
|
+
for key, value in answers.items()
|
|
75
|
+
if _answer(value)
|
|
76
|
+
],
|
|
77
|
+
}
|
|
File without changes
|