diff --git a/CHANGELOG.md b/CHANGELOG.md index fd92dfd..0b3d54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,25 @@ ## Unreleased +- Export typed terminal Calls webhooks. `call.ended` preserves a nullable caller + identity for `pod_lost`; `call.telemetry` preserves cumulative traffic values. + +- Browser WebRTC calls now receive participant joins, state changes, and + departures through the lifecycle stream. Late invitation replies cannot + overwrite newer roster state or restore a participant who already left. The + TypeScript webhook catalog exports payload types for the same three events. + - Keep call controls and media available after a failed reject or hangup, with a localized retry message in React and custom elements. - Send call rejection without a request body, matching the API contract. -- Refreshed the pinned API contracts and recorded the five implemented - programmatic Calls methods. The ledger retains missing QuickLink and - Platform methods and removes obsolete widget mappings. Coverage is 226 of - 402 operations, with 103 missing and 73 excluded; no new SDK methods or - package releases are included in this reconciliation. +- Refreshed the pinned API contracts through the completed Calls and TURN + producer changes. The snapshots now include literal webhook event names, + nullable terminal-call callers, participant lifecycle events, client-token + delegation scopes, and TURN health diagnostics. Coverage remains 226 of 402 + operations, with 103 missing and 73 excluded; no SDK package release is + included in this reconciliation. - Added `createBrowserCalls`: direct client-token placement, shared call models and lifecycle events connected to the existing WebRTC controller and UI. Browser mode auto-answers remotely; Answer attaches local media and Reject diff --git a/README.md b/README.md index 14c5347..e902231 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The development branch contains the TypeScript server SDK, a framework-neutral browser runtime, shared UI contracts, Web Components, React bindings, thin Next.js server helpers, and a production-gated developer assistant. It follows the Messaging and Platform contracts recorded at source revision -`3bf3a6ba3de8b19a547afd16fca1f3b368d7d4e7`. Graph-compatible APIs are outside +`8c244aab0e5626d101a2c8c4915287427f39e014`. Graph-compatible APIs are outside this SDK's initial scope. ## Package architecture @@ -277,12 +277,11 @@ if (isEvent(event, "message.received")) { Native deliveries use the hexadecimal `X-Webhook-Signature` value. The helper also accepts the `sha256=` compatibility form. Verification uses -HMAC-SHA256 and constant-time comparison over the unmodified bytes. Known -events narrow to exported payload types for all 33 event schemas in the pinned -Messaging contract, including messages, sessions, groups, presence, contacts, -chats, calls, labels, history sync, command results, and business quick -replies. Unknown event names and payloads are preserved for forward -compatibility. +HMAC-SHA256 and constant-time comparison over the unmodified bytes. Recognized +events narrow to exported payload types, including messages, sessions, groups, +presence, contacts, chats, calls, labels, history sync, command results, and +business quick replies. Unknown event names and payloads are preserved for +forward compatibility. Messaging server credentials can manage webhook registrations through `MessagingClient.webhooks`. The Platform contract also defines organization @@ -368,8 +367,9 @@ and video) or `cloudApi` (the WhatsApp Business Calling API, audio only). Every component gates on the snapshot's `capabilities`, never on the line name. The controller also owns capture/playback device choice (`setPreferredDevices`, `switchDevice`, `refreshDevices`) so a microphone or camera swap mid-call is a -track replacement, not a renegotiation. The shared call model supports participant invitations. Live roster updates -are available on programmatic media sockets, not the browser WebRTC path. +track replacement, not a renegotiation. The shared call model supports +participant invitations. Browser WebRTC calls receive live participant joins, +state changes, and departures through the lifecycle stream. `@polymorfa/react` ships the complete call UI: `CallSurface` (incoming card, stage, control dock, and a pop-out window), plus `IncomingCallCard`, diff --git a/contracts/README.md b/contracts/README.md index 3d795cd..ad9ad2e 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,7 +2,7 @@ The snapshots are byte-identical copies of the Messaging and Platform OpenAPI files at `polymorfa/polymorfa` commit -`3bf3a6ba3de8b19a547afd16fca1f3b368d7d4e7`. `source.json` records their original +`8c244aab0e5626d101a2c8c4915287427f39e014`. `source.json` records their original paths and SHA-256 hashes. `coverage.json` uses the same source revision. | Status | Operations | @@ -20,8 +20,18 @@ successful live call. ## Reconciliation -This snapshot adds 22 operations and removes 11 widget operations relative to -the previous snapshot. The added operations are five implemented Calls +This refresh adds and removes no operations. Two Messaging operation +fingerprints changed: browser candidate retrieval remains excluded from the +server SDK, and `MessagingClient.voip.agentToken` remains covered by its typed +method and request test. Schema updates include literal webhook event names, +nullable terminal-call callers, participant lifecycle events, client-token +delegation scopes, and TURN health diagnostics. These changes do not alter the +operation coverage totals or the existing missing-operation inventory. + +## Previous reconciliation + +The previous snapshot added 22 operations and removed 11 widget operations. +The added operations were five implemented Calls operations, ten missing QuickLink operations, and seven Console-only operations excluded by their credential contract. diff --git a/contracts/coverage.json b/contracts/coverage.json index 0173cd2..37fdc15 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "3bf3a6ba3de8b19a547afd16fca1f3b368d7d4e7", + "sourceCommit": "8c244aab0e5626d101a2c8c4915287427f39e014", "excludedContracts": [ { "family": "graph", @@ -359,7 +359,7 @@ "method": "GET", "path": "/api/voip/calls/{id}/candidates", "operationId": "voipCandidates", - "fingerprint": "7fe4f817d13b49bf9910a47de56d9cb398d97e15f4573624670a75c94a30a212", + "fingerprint": "a0a6899c811800145bd65e05c019c38d68bdffb0b4bb269789ce50baa473021d", "typescript": { "status": "excluded", "reason": "Browser client-token signaling route served by @polymorfa/browser (CallsSignalingClient over BrowserTransport); it does not accept the server credential used by the TypeScript server SDK.", @@ -1162,7 +1162,7 @@ "method": "POST", "path": "/api/voip/calls/{id}/agent-token", "operationId": "voipAgentToken", - "fingerprint": "03d4c5cd14a2c755d10ffb96fc7cbeee43b8ccfd7635d5922fdd0a5087d196a3", + "fingerprint": "789a8b72ea1c84104018e36eede3e84c52272dc242a0781491e81d4d84703678", "typescript": { "status": "covered", "method": "MessagingClient.voip.agentToken" diff --git a/contracts/openapi.messaging.json b/contracts/openapi.messaging.json index 9e405ab..fe03803 100644 --- a/contracts/openapi.messaging.json +++ b/contracts/openapi.messaging.json @@ -281,6 +281,9 @@ }, "event": { "type": "string", + "enum": [ + "message.received" + ], "description": "Event type" }, "payload": { @@ -339,6 +342,9 @@ }, "event": { "type": "string", + "enum": [ + "message.sent" + ], "description": "Event type" }, "payload": { @@ -401,6 +407,9 @@ }, "event": { "type": "string", + "enum": [ + "message.ack" + ], "description": "Event type" }, "payload": { @@ -432,6 +441,9 @@ }, "event": { "type": "string", + "enum": [ + "message.revoked" + ], "description": "Event type" }, "payload": { @@ -463,6 +475,9 @@ }, "event": { "type": "string", + "enum": [ + "message.reaction" + ], "description": "Event type" }, "payload": { @@ -494,6 +509,9 @@ }, "event": { "type": "string", + "enum": [ + "message.edited" + ], "description": "Event type" }, "payload": { @@ -525,6 +543,9 @@ }, "event": { "type": "string", + "enum": [ + "message.update" + ], "description": "Event type" }, "payload": { @@ -579,6 +600,9 @@ }, "event": { "type": "string", + "enum": [ + "message.delete" + ], "description": "Event type" }, "payload": { @@ -636,6 +660,9 @@ }, "event": { "type": "string", + "enum": [ + "message.vote" + ], "description": "Event type" }, "payload": { @@ -681,6 +708,9 @@ }, "event": { "type": "string", + "enum": [ + "session.status" + ], "description": "Event type" }, "payload": { @@ -723,6 +753,9 @@ }, "event": { "type": "string", + "enum": [ + "session.qr" + ], "description": "Event type" }, "payload": { @@ -779,6 +812,9 @@ }, "event": { "type": "string", + "enum": [ + "session.connected" + ], "description": "Event type" }, "payload": { @@ -821,6 +857,9 @@ }, "event": { "type": "string", + "enum": [ + "session.logged_out" + ], "description": "Event type" }, "payload": { @@ -883,6 +922,9 @@ }, "event": { "type": "string", + "enum": [ + "session.phone_offline" + ], "description": "Event type" }, "payload": { @@ -934,6 +976,9 @@ }, "event": { "type": "string", + "enum": [ + "group.update" + ], "description": "Event type" }, "payload": { @@ -1000,6 +1045,9 @@ }, "event": { "type": "string", + "enum": [ + "group.participant" + ], "description": "Event type" }, "payload": { @@ -1060,6 +1108,9 @@ }, "event": { "type": "string", + "enum": [ + "presence.update" + ], "description": "Event type" }, "payload": { @@ -1136,6 +1187,9 @@ }, "event": { "type": "string", + "enum": [ + "contact.update" + ], "description": "Event type" }, "payload": { @@ -1184,6 +1238,9 @@ }, "event": { "type": "string", + "enum": [ + "chat.archive" + ], "description": "Event type" }, "payload": { @@ -1233,6 +1290,9 @@ }, "event": { "type": "string", + "enum": [ + "chat.mute" + ], "description": "Event type" }, "payload": { @@ -1279,6 +1339,9 @@ }, "event": { "type": "string", + "enum": [ + "chat.read" + ], "description": "Event type" }, "payload": { @@ -1321,6 +1384,9 @@ }, "event": { "type": "string", + "enum": [ + "chat.clear" + ], "description": "Event type" }, "payload": { @@ -1363,6 +1429,9 @@ }, "event": { "type": "string", + "enum": [ + "chat.delete" + ], "description": "Event type" }, "payload": { @@ -1415,6 +1484,9 @@ }, "event": { "type": "string", + "enum": [ + "call.received" + ], "description": "Event type" }, "payload": { @@ -1465,6 +1537,9 @@ }, "event": { "type": "string", + "enum": [ + "call.missed" + ], "description": "Event type" }, "payload": { @@ -1511,6 +1586,9 @@ }, "event": { "type": "string", + "enum": [ + "call.accepted" + ], "description": "Event type" }, "payload": { @@ -1557,6 +1635,9 @@ }, "event": { "type": "string", + "enum": [ + "call.rejected" + ], "description": "Event type" }, "payload": { @@ -1575,7 +1656,34 @@ "type": "object", "properties": { "from": { - "$ref": "#/components/schemas/JIDRef" + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + }, + "username": { + "type": "string" + }, + "lid": { + "type": "string", + "deprecated": true + }, + "mode": { + "type": "string", + "enum": [ + "pn", + "lid" + ], + "deprecated": true + } + }, + "required": [ + "id" + ] }, "callId": { "type": "string" @@ -1587,7 +1695,7 @@ }, "reason": { "type": "string", - "description": "Termination reason: user_hangup | timeout | lost_connection | rejected | capacity.", + "description": "Termination reason: user_hangup | timeout | lost_connection | rejected | capacity | pod_lost.", "example": "user_hangup" }, "direction": { @@ -1631,6 +1739,9 @@ }, "event": { "type": "string", + "enum": [ + "call.ended" + ], "description": "Event type" }, "payload": { @@ -1733,6 +1844,9 @@ }, "event": { "type": "string", + "enum": [ + "call.telemetry" + ], "description": "Event type" }, "payload": { @@ -1747,6 +1861,182 @@ "payload" ] }, + "CallParticipant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "audioMuted": { + "type": "boolean", + "enum": [ + false + ], + "description": "Reserved; false until WhatsApp exposes authoritative participant mute state." + }, + "video": { + "type": "boolean", + "enum": [ + false + ], + "description": "Reserved; false until WhatsApp exposes authoritative participant video state." + }, + "state": { + "type": "string", + "enum": [ + "invited", + "ringing", + "connected", + "left" + ] + } + }, + "required": [ + "id", + "handle", + "audioMuted", + "video", + "state" + ] + }, + "CallParticipantPayload": { + "type": "object", + "properties": { + "callId": { + "type": "string" + }, + "participant": { + "$ref": "#/components/schemas/CallParticipant" + } + }, + "required": [ + "callId", + "participant" + ] + }, + "CallParticipantJoinedEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique event identifier" + }, + "session": { + "type": "string", + "description": "Session that produced this event" + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp" + }, + "event": { + "type": "string", + "enum": [ + "call.participant_joined" + ], + "description": "Event type" + }, + "payload": { + "$ref": "#/components/schemas/CallParticipantPayload" + } + }, + "required": [ + "id", + "session", + "timestamp", + "event", + "payload" + ] + }, + "CallParticipantStateEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique event identifier" + }, + "session": { + "type": "string", + "description": "Session that produced this event" + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp" + }, + "event": { + "type": "string", + "enum": [ + "call.participant_state" + ], + "description": "Event type" + }, + "payload": { + "$ref": "#/components/schemas/CallParticipantPayload" + } + }, + "required": [ + "id", + "session", + "timestamp", + "event", + "payload" + ] + }, + "CallParticipantLeftPayload": { + "type": "object", + "properties": { + "callId": { + "type": "string" + }, + "participantId": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "callId", + "participantId" + ] + }, + "CallParticipantLeftEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique event identifier" + }, + "session": { + "type": "string", + "description": "Session that produced this event" + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp" + }, + "event": { + "type": "string", + "enum": [ + "call.participant_left" + ], + "description": "Event type" + }, + "payload": { + "$ref": "#/components/schemas/CallParticipantLeftPayload" + } + }, + "required": [ + "id", + "session", + "timestamp", + "event", + "payload" + ] + }, "NewsletterUpdatePayload": { "type": "object", "properties": { @@ -1782,6 +2072,9 @@ }, "event": { "type": "string", + "enum": [ + "newsletter.update" + ], "description": "Event type" }, "payload": { @@ -1856,6 +2149,9 @@ }, "event": { "type": "string", + "enum": [ + "blocklist.update" + ], "description": "Event type" }, "payload": { @@ -1953,6 +2249,9 @@ }, "event": { "type": "string", + "enum": [ + "labels.update" + ], "description": "Event type" }, "payload": { @@ -2034,6 +2333,9 @@ }, "event": { "type": "string", + "enum": [ + "business.quick_reply.update" + ], "description": "Event type" }, "payload": { @@ -2129,6 +2431,9 @@ }, "event": { "type": "string", + "enum": [ + "history.sync" + ], "description": "Event type" }, "payload": { @@ -2186,6 +2491,9 @@ }, "event": { "type": "string", + "enum": [ + "command.result" + ], "description": "Event type" }, "payload": { @@ -2266,6 +2574,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.enabled" + ], "description": "Event type" }, "payload": { @@ -2342,6 +2653,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.created" + ], "description": "Event type" }, "payload": { @@ -2432,6 +2746,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.updated" + ], "description": "Event type" }, "payload": { @@ -2518,6 +2835,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.archiving" + ], "description": "Event type" }, "payload": { @@ -2594,6 +2914,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.archived" + ], "description": "Event type" }, "payload": { @@ -2670,6 +2993,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.restored" + ], "description": "Event type" }, "payload": { @@ -2751,6 +3077,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.pairing_link.created" + ], "description": "Event type" }, "payload": { @@ -2832,6 +3161,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.pairing_link.opened" + ], "description": "Event type" }, "payload": { @@ -2918,6 +3250,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.pairing_link.connected" + ], "description": "Event type" }, "payload": { @@ -3004,6 +3339,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.pairing_link.failed" + ], "description": "Event type" }, "payload": { @@ -3085,6 +3423,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.pairing_link.expired" + ], "description": "Event type" }, "payload": { @@ -3175,6 +3516,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.pairing_link.revoked" + ], "description": "Event type" }, "payload": { @@ -3260,6 +3604,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.number.attached" + ], "description": "Event type" }, "payload": { @@ -3346,6 +3693,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.number.transferred" + ], "description": "Event type" }, "payload": { @@ -3432,6 +3782,9 @@ }, "event": { "type": "string", + "enum": [ + "customer.number.disconnected" + ], "description": "Event type" }, "payload": { @@ -10978,7 +11331,7 @@ "BearerAuth": { "type": "http", "scheme": "bearer", - "description": "Polymorfa API key (pmfa_ prefix) or client token (pmfa_ct_ prefix)." + "description": "Polymorfa organization key (pmfa_ prefix), project token (pmfa_pt_ prefix) where supported, or short-lived client token (pmfa_ct_ prefix). Organization and project keys may be personal or service-account owned and enforce selected action scopes. Organization keys cover the team and its projects; project tokens stay inside one project. Client tokens stay inside their live project and session and enforce their client rules." } } }, @@ -11015,7 +11368,7 @@ "security": [], "responses": { "200": { - "description": "All services healthy", + "description": "Required readiness checks pass; optional diagnostics may report degraded or in-progress state", "content": { "application/json": { "schema": { @@ -11210,7 +11563,15 @@ "BearerAuth": [] } ], - "x-required-scope": "sessions:manage", + "x-required-scopes": [ + "sessions:manage", + "messages:write", + "contacts:read", + "presence:read", + "presence:observe", + "mcp" + ], + "x-required-scope-mode": "all", "requestBody": { "content": { "application/json": { @@ -11283,9 +11644,9 @@ } }, "x-mint": { - "content": "sessions:manage" + "content": "sessions:manage messages:write contacts:read presence:read presence:observe mcp" }, - "description": "**Required scope:** `sessions:manage`" + "description": "**Required scopes (all):** `sessions:manage` · `messages:write` · `contacts:read` · `presence:read` · `presence:observe` · `mcp`" } }, "/api/sessions/{session}/client-rules": { @@ -11389,7 +11750,15 @@ "BearerAuth": [] } ], - "x-required-scope": "sessions:manage", + "x-required-scopes": [ + "sessions:manage", + "messages:write", + "contacts:read", + "presence:read", + "presence:observe", + "mcp" + ], + "x-required-scope-mode": "all", "parameters": [ { "schema": { @@ -11472,9 +11841,9 @@ } }, "x-mint": { - "content": "sessions:manage" + "content": "sessions:manage messages:write contacts:read presence:read presence:observe mcp" }, - "description": "**Required scope:** `sessions:manage`" + "description": "**Required scopes (all):** `sessions:manage` · `messages:write` · `contacts:read` · `presence:read` · `presence:observe` · `mcp`" }, "delete": { "operationId": "deleteClientRules", @@ -14519,7 +14888,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -14651,7 +15020,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -14843,6 +15212,26 @@ } } }, + "409": { + "description": "Call not ready — no media session on the owning pod yet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "410": { + "description": "Call ended — the pod that owned its media is gone (pod_lost)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -14852,6 +15241,16 @@ } } } + }, + "503": { + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "x-mint": { @@ -14866,7 +15265,7 @@ "VoIP" ], "summary": "Place an outbound call from a session", - "description": "**Required scope:** `sessions:manage`\n\nOriginates a WhatsApp call to `to` (an E.164 phone number or a WhatsApp JID) from the named session. The call rings on the callee's device: `call.accepted` follows when they pick up and `call.ended` when either side hangs up. Attach media like any other call — `/offer` for a browser, the SDK media socket for programmatic audio/video. Client tokens place from their bound session; server keys must name one.", + "description": "**Required scope:** `sessions:manage`\n\nOriginates a WhatsApp call to `to` (an E.164 phone number or a WhatsApp JID) from the named session. The call rings on the callee's device: `call.accepted` follows when they pick up and `call.ended` when either side hangs up. Attach media like any other call — `/offer` for a browser, the SDK media socket for programmatic audio/video. Client tokens place from their bound session; server keys must name one. Outbound starts share a rate limit per organization and source session across server keys and client tokens; client-token rules also apply.", "security": [ { "BearerAuth": [] @@ -14954,7 +15353,7 @@ } }, "429": { - "description": "Call setup rate exceeded for this client token, or its concurrency limit is reached", + "description": "Outbound call rate exceeded for this organization and source session, or a client-token setup or concurrency limit was reached", "content": { "application/json": { "schema": { @@ -14984,7 +15383,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -15116,7 +15515,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -15239,7 +15638,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -15381,7 +15780,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -15602,7 +16001,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -15694,6 +16093,26 @@ } } }, + "409": { + "description": "Call not ready — no media session on the owning pod yet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "410": { + "description": "Call ended — the pod that owned its media is gone (pod_lost)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -15703,6 +16122,16 @@ } } } + }, + "503": { + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "x-mint": { @@ -15818,7 +16247,7 @@ } }, "503": { - "description": "Call refused — the owning pod is at capacity or draining", + "description": "Call unavailable — the owning pod is at capacity or draining, or call authority cannot be verified", "content": { "application/json": { "schema": { @@ -15846,7 +16275,15 @@ "BearerAuth": [] } ], - "x-required-scope": "sessions:manage", + "x-required-scopes": [ + "sessions:manage", + "messages:write", + "contacts:read", + "presence:read", + "presence:observe", + "mcp" + ], + "x-required-scope-mode": "all", "requestBody": { "content": { "application/json": { @@ -15919,9 +16356,9 @@ } }, "x-mint": { - "content": "sessions:manage" + "content": "sessions:manage messages:write contacts:read presence:read presence:observe mcp" }, - "description": "**Required scope:** `sessions:manage`" + "description": "**Required scopes (all):** `sessions:manage` · `messages:write` · `contacts:read` · `presence:read` · `presence:observe` · `mcp`" } }, "/api/{session}/groups": { @@ -29674,7 +30111,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29698,7 +30135,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29722,7 +30159,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29746,7 +30183,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29770,7 +30207,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29794,7 +30231,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29818,7 +30255,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29842,7 +30279,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29866,7 +30303,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29890,7 +30327,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29914,7 +30351,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29938,7 +30375,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29962,7 +30399,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -29986,7 +30423,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30010,7 +30447,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30034,7 +30471,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30058,7 +30495,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30082,7 +30519,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30106,7 +30543,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30130,7 +30567,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30154,7 +30591,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30178,7 +30615,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30202,7 +30639,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30226,7 +30663,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30250,7 +30687,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30274,7 +30711,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30298,7 +30735,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30322,7 +30759,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30346,7 +30783,79 @@ } }, "responses": { - "200": { + "2XX": { + "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." + } + } + } + }, + "call.participant_joined": { + "post": { + "operationId": "onCallParticipantJoined", + "summary": "Participant joined a call", + "tags": [ + "Webhooks" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CallParticipantJoinedEvent" + } + } + } + }, + "responses": { + "2XX": { + "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." + } + } + } + }, + "call.participant_state": { + "post": { + "operationId": "onCallParticipantState", + "summary": "Participant call state changed", + "tags": [ + "Webhooks" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CallParticipantStateEvent" + } + } + } + }, + "responses": { + "2XX": { + "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." + } + } + } + }, + "call.participant_left": { + "post": { + "operationId": "onCallParticipantLeft", + "summary": "Participant left a call", + "tags": [ + "Webhooks" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CallParticipantLeftEvent" + } + } + } + }, + "responses": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30370,7 +30879,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30394,7 +30903,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30418,7 +30927,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30442,7 +30951,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30466,7 +30975,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30490,7 +30999,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30514,7 +31023,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30538,7 +31047,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30562,7 +31071,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30586,7 +31095,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30610,7 +31119,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30634,7 +31143,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30658,7 +31167,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30682,7 +31191,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30706,7 +31215,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30730,7 +31239,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30754,7 +31263,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30778,7 +31287,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30802,7 +31311,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30826,7 +31335,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } @@ -30850,7 +31359,7 @@ } }, "responses": { - "200": { + "2XX": { "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." } } diff --git a/contracts/openapi.platform.json b/contracts/openapi.platform.json index e2e3b2a..ef03e63 100644 --- a/contracts/openapi.platform.json +++ b/contracts/openapi.platform.json @@ -17196,7 +17196,7 @@ "ApiKeyAuth": { "type": "http", "scheme": "bearer", - "description": "Polymorfa server credential: a scoped pmfa_ organization key, or a pmfa_pt_ project token on project- and session-bound operations inside its exact authenticated project. Listener and browser client credentials are rejected." + "description": "Polymorfa server credential: a scoped pmfa_ organization key, or a pmfa_pt_ project token on project- and session-bound operations inside its exact authenticated project. Keys may be personal or service-account owned. Both enforce selected action scopes; organization keys can address team resources and every project in the organization. Listener and browser client credentials are rejected." }, "ConsoleSession": { "type": "http", diff --git a/contracts/source.json b/contracts/source.json index bff029e..4fba6a6 100644 --- a/contracts/source.json +++ b/contracts/source.json @@ -1,16 +1,16 @@ { "repository": "polymorfa/polymorfa", - "commit": "3bf3a6ba3de8b19a547afd16fca1f3b368d7d4e7", + "commit": "8c244aab0e5626d101a2c8c4915287427f39e014", "contracts": { "messaging": { "sourcePath": "apps/api/docs/openapi.json", "snapshotPath": "contracts/openapi.messaging.json", - "sha256": "9f1689c4043042884bb0b1d6cdaac39b2c8be4f078c1d703afcb597243fa1ddb" + "sha256": "a60bc482b8e830b063895708fbf0b9ea68a05b9d8df3f7256df085349d88ef2e" }, "platform": { "sourcePath": "apps/api/docs/openapi.management.json", "snapshotPath": "contracts/openapi.platform.json", - "sha256": "2246c197f7308eb7448fad4b718ff3400ddc7fd9dc378ac8ba8f38662e615876" + "sha256": "47a1d3e94f8ebaad527968e90053fb11987b639e38daa8d899db6bd7c4030f0d" } } } diff --git a/packages/browser/README.md b/packages/browser/README.md index 305dbe2..c9004d7 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -78,9 +78,9 @@ one widget; additional inbound calls are declined. The shared call's PCM/video-frame streams belong to programmatic socket media. Use `controller.localStream` and `controller.remoteStream` for browser media. -Participant invitations return the invited participant, but WebRTC does not -receive the tagged socket's roster updates. Do not treat its participants list -or the reserved `audioMuted`/`video` fields as authoritative live presence. +Participant invitations return the invited participant. The lifecycle stream +keeps `call.participants` and participant `state` in sync for browser WebRTC +calls. `audioMuted` and `video` remain reserved as `false`. `createSignalingCallsBackend`, `CallsSocket` and `IncomingCallRelay` remain available for applications that supply their own placement or event channel. diff --git a/packages/calls/README.md b/packages/calls/README.md index 10133bd..854284f 100644 --- a/packages/calls/README.md +++ b/packages/calls/README.md @@ -46,6 +46,10 @@ the socket; text frames are JSON control (`ready`, `hangup`, `video_state`, `participant_*`). The framing is defined once in `protocol.ts`; the pod implements the same contract. +Calls created with `mediaMode: "external"` receive the same participant roster +through the lifecycle socket. `participantJoined`, `participantState`, and +`participantLeft` update `call.participants` without opening a media socket. + ## Runtime Node 22+ for the global `WebSocket`, or pass `WebSocket` from `ws` on Node 20. diff --git a/packages/calls/src/call.ts b/packages/calls/src/call.ts index 7583d2a..61e4592 100644 --- a/packages/calls/src/call.ts +++ b/packages/calls/src/call.ts @@ -1,7 +1,7 @@ import type { CallsApi } from "./api.js"; import { Emitter } from "./events.js"; import { MediaSocket, type MediaSocketOptions } from "./media.js"; -import type { Participant, VideoFrame } from "./protocol.js"; +import type { MediaControlFrame, Participant, VideoFrame } from "./protocol.js"; export type CallDirection = "inbound" | "outbound"; @@ -130,6 +130,9 @@ export class Call extends Emitter { readonly #mediaOptions: Omit; readonly #now: () => number; readonly #participants = new Map(); + readonly #departedParticipants = new Set(); + readonly #participantRevisions = new Map(); + #rosterRevision = 0; #state: CallState; #media: MediaSocket | undefined; #endReason: CallEndReason | undefined; @@ -252,9 +255,16 @@ export class Call extends Emitter { async addParticipant(to: string): Promise { if (this.#state === "ended") throw new Error("Cannot add a participant to an ended call."); + const requestedAtRevision = this.#rosterRevision; const participant = await this.#api.addParticipant(this.id, to); - this.#participants.set(participant.id, participant); - this.emit("participantJoined", participant); + // The lifecycle stream may move this participant forward, or report it + // left, while the HTTP request is in flight. Its acknowledgement is then + // older than the roster and must not overwrite or restore that entry. + if ( + (this.#participantRevisions.get(participant.id) ?? 0) <= + requestedAtRevision + ) + this.#applyInviteReply(participant); return participant; } @@ -281,6 +291,61 @@ export class Call extends Emitter { this.#end(reason); } + /** @internal Apply a validated participant update from either transport. */ + _remoteParticipant(frame: ParticipantControlFrame): void { + if (this.ended) return; + const participantId = + frame.type === "participant_left" + ? frame.participantId + : frame.participant.id; + this.#rosterRevision += 1; + this.#participantRevisions.set(participantId, this.#rosterRevision); + this.#applyParticipant(frame); + } + + #applyInviteReply(participant: Participant): void { + if (this.ended) return; + const previous = this.#participants.get(participant.id); + if ( + previous !== undefined && + participantStateRank(participant.state) < + participantStateRank(previous.state) + ) + return; + if (previous !== undefined && sameParticipant(previous, participant)) + return; + this.#rosterRevision += 1; + this.#participantRevisions.set(participant.id, this.#rosterRevision); + this.#applyParticipant({ type: "participant_joined", participant }); + } + + #applyParticipant(frame: ParticipantControlFrame): void { + if (this.ended) return; + if (frame.type === "participant_left") { + if (this.#departedParticipants.has(frame.participantId)) return; + this.#departedParticipants.add(frame.participantId); + this.#participants.delete(frame.participantId); + this.emit("participantLeft", frame.participantId, frame.reason); + return; + } + + const participant = frame.participant; + if (participant.state === "left") { + this.#applyParticipant({ + type: "participant_left", + participantId: participant.id, + }); + return; + } + this.#departedParticipants.delete(participant.id); + const previous = this.#participants.get(participant.id); + if (previous !== undefined && sameParticipant(previous, participant)) + return; + this.#participants.set(participant.id, participant); + if (previous === undefined) this.emit("participantJoined", participant); + else this.emit("participantState", participant); + } + /** * Notify an externally managed call that its media connected. An outbound * call still waits for the remote party to accept. Socket media ignores this. @@ -311,18 +376,19 @@ export class Call extends Emitter { media.on("audio", (pcm) => this.audio._push(pcm)); media.on("video", (frame) => this.video?._frame(frame)); media.on("videoState", (enabled) => this.video?._state(enabled)); - media.on("participantJoined", (p) => { - this.#participants.set(p.id, p); - this.emit("participantJoined", p); - }); - media.on("participantState", (p) => { - this.#participants.set(p.id, p); - this.emit("participantState", p); - }); - media.on("participantLeft", (id, reason) => { - this.#participants.delete(id); - this.emit("participantLeft", id, reason); - }); + media.on("participantJoined", (participant) => + this._remoteParticipant({ type: "participant_joined", participant }), + ); + media.on("participantState", (participant) => + this._remoteParticipant({ type: "participant_state", participant }), + ); + media.on("participantLeft", (participantId, reason) => + this._remoteParticipant({ + type: "participant_left", + participantId, + ...(reason === undefined ? {} : { reason }), + }), + ); media.on("error", (error) => this.emit("error", error)); media.on("hangup", () => this.#end("remote_hangup")); media.on("close", () => { @@ -375,3 +441,31 @@ export class Call extends Emitter { this.removeAllListeners(); } } + +type ParticipantControlFrame = Extract< + MediaControlFrame, + { type: "participant_joined" | "participant_state" | "participant_left" } +>; + +function participantStateRank(state: Participant["state"]): number { + switch (state) { + case "invited": + return 0; + case "ringing": + return 1; + case "connected": + return 2; + case "left": + return 3; + } +} + +function sameParticipant(a: Participant, b: Participant): boolean { + return ( + a.id === b.id && + a.handle === b.handle && + a.audioMuted === b.audioMuted && + a.video === b.video && + a.state === b.state + ); +} diff --git a/packages/calls/src/client.ts b/packages/calls/src/client.ts index c75623e..c87eef9 100644 --- a/packages/calls/src/client.ts +++ b/packages/calls/src/client.ts @@ -7,6 +7,7 @@ import { import { Call, type CallEndReason } from "./call.js"; import { Emitter } from "./events.js"; import { LifecycleSocket, type LifecycleEvent } from "./lifecycle.js"; +import { parseMediaControlValue, type MediaControlFrame } from "./protocol.js"; export interface CallsClientOptions { /** Server API key (`pmfa_…`). Never ship this to a browser. */ @@ -239,6 +240,17 @@ export class CallsClient extends Emitter { } this.#apply(existing, event); return; + case "call.participant_joined": + case "call.participant_state": + case "call.participant_left": + if (this.#o.mediaMode !== "external") return; + if (participantControlFrom(event) === undefined) return; + if (existing === undefined) { + this.#buffer(event); + return; + } + this.#apply(existing, event); + return; default: return; } @@ -282,6 +294,14 @@ export class CallsClient extends Emitter { case "call.rejected": call._remoteEnded("rejected"); return; + case "call.participant_joined": + case "call.participant_state": + case "call.participant_left": { + if (this.#o.mediaMode !== "external") return; + const frame = participantControlFrom(event); + if (frame !== undefined) call._remoteParticipant(frame); + return; + } default: return; } @@ -297,7 +317,59 @@ export class CallsClient extends Emitter { queue = []; this.#pendingEvents.set(event.callId, queue); } - if (queue.length < PENDING_EVENTS_PER_ID) queue.push(event); + const terminal = (item: LifecycleEvent) => + item.event === "call.ended" || + item.event === "call.missed" || + item.event === "call.rejected"; + // A completed call cannot be revived by later roster or accepted events. + if (queue.some(terminal)) return; + if (terminal(event)) { + queue.splice(0, queue.length, event); + return; + } + if (event.event === "call.accepted") { + if (queue.some((item) => item.event === "call.accepted")) return; + // Reserve lifecycle progress even when roster updates filled the queue. + if (queue.length >= PENDING_EVENTS_PER_ID) queue.shift(); + queue.push(event); + return; + } + const frame = participantControlFrom(event); + if (frame !== undefined) { + const id = + frame.type === "participant_left" + ? frame.participantId + : frame.participant.id; + const previous = queue.findIndex((item) => { + const queued = participantControlFrom(item); + return ( + queued !== undefined && + (queued.type === "participant_left" + ? queued.participantId + : queued.participant.id) === id + ); + }); + if (previous >= 0) { + const queued = participantControlFrom(queue[previous]!); + // A tracked call suppresses duplicate departures after emitting the + // first one's metadata. Preserve the same behavior while pending. + if ( + queued !== undefined && + isParticipantDeparture(queued) && + isParticipantDeparture(frame) + ) + return; + queue.splice(previous, 1); + } + } + if (queue.length >= PENDING_EVENTS_PER_ID) { + const oldestRoster = queue.findIndex( + (item) => participantControlFrom(item) !== undefined, + ); + if (oldestRoster < 0) return; + queue.splice(oldestRoster, 1); + } + queue.push(event); } } @@ -351,3 +423,47 @@ function endReasonFrom(value: unknown): CallEndReason { return "unknown"; } } + +type ParticipantControlFrame = Extract< + MediaControlFrame, + { type: "participant_joined" | "participant_state" | "participant_left" } +>; + +function isParticipantDeparture(frame: ParticipantControlFrame): boolean { + return ( + frame.type === "participant_left" || frame.participant.state === "left" + ); +} + +function participantControlFrom( + event: LifecycleEvent, +): ParticipantControlFrame | undefined { + if (event.payload["callId"] !== event.callId) return undefined; + let value: unknown; + switch (event.event) { + case "call.participant_joined": + value = { + type: "participant_joined", + participant: event.payload["participant"], + }; + break; + case "call.participant_state": + value = { + type: "participant_state", + participant: event.payload["participant"], + }; + break; + case "call.participant_left": + value = { + type: "participant_left", + participantId: event.payload["participantId"], + ...(typeof event.payload["reason"] !== "string" + ? {} + : { reason: event.payload["reason"] }), + }; + break; + default: + return undefined; + } + return parseMediaControlValue(value) as ParticipantControlFrame | undefined; +} diff --git a/packages/calls/src/protocol.ts b/packages/calls/src/protocol.ts index 077f58f..696b6f7 100644 --- a/packages/calls/src/protocol.ts +++ b/packages/calls/src/protocol.ts @@ -200,12 +200,17 @@ export function parseMediaControl( data: unknown, ): MediaControlFrame | undefined { if (typeof data !== "string") return undefined; - let parsed: unknown; try { - parsed = JSON.parse(data); + return parseMediaControlValue(JSON.parse(data)); } catch { return undefined; } +} + +/** Validate an already-decoded media control value. @internal */ +export function parseMediaControlValue( + parsed: unknown, +): MediaControlFrame | undefined { if (parsed === null || typeof parsed !== "object") return undefined; const f = parsed as Record; switch (f["type"]) { diff --git a/packages/calls/test/client.test.ts b/packages/calls/test/client.test.ts index f3cbaff..ddd3636 100644 --- a/packages/calls/test/client.test.ts +++ b/packages/calls/test/client.test.ts @@ -1,13 +1,18 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { + Call, CallsClient, MediaSocket, encodeAudioFrame, - type Call, + type CallsClientOptions, + type Participant, } from "../src/index.js"; import { FakeWebSocket, fakeApi, flush, timers } from "./helpers.js"; -function clientWith(api = fakeApi()) { +function clientWith( + api = fakeApi(), + options: Pick = {}, +) { FakeWebSocket.instances = []; const t = timers(); const client = new CallsClient({ @@ -20,6 +25,7 @@ function clientWith(api = fakeApi()) { clearTimeout: t.clearTimeout, random: () => 0.5, now: () => 1_000, + ...options, }); return { client, api, t, ws: (i = 0) => FakeWebSocket.instances[i]! }; } @@ -388,6 +394,310 @@ describe("CallsClient", () => { expect(call!.participants.map((p) => p.id)).toEqual([added.id]); }); + it("tracks lifecycle roster events only for the matching external-media call", async () => { + const h = clientWith(fakeApi(), { mediaMode: "external" }); + const life = await connected(h); + let call: Call | undefined; + h.client.on("incoming", (c) => (call = c)); + ring(life); + + const joined: string[] = []; + const states: string[] = []; + const left: string[] = []; + call!.on("participantJoined", (p) => joined.push(p.state)); + call!.on("participantState", (p) => states.push(p.state)); + call!.on("participantLeft", (id) => left.push(id)); + const participant = { + id: "p1", + handle: "+15550101", + audioMuted: false, + video: false, + state: "ringing", + } satisfies Participant; + + life.text({ + type: "event", + event: "call.participant_joined", + callId: "CALL-1", + payload: { callId: "CALL-1", participant }, + timestamp: "", + }); + life.text({ + type: "event", + event: "call.participant_state", + callId: "CALL-1", + payload: { + callId: "CALL-1", + participant: { ...participant, audioMuted: true, state: "connected" }, + }, + timestamp: "", + }); + // The event envelope and payload must identify the same call. + life.text({ + type: "event", + event: "call.participant_state", + callId: "CALL-1", + payload: { + callId: "CALL-OTHER", + participant: { ...participant, handle: "wrong-call" }, + }, + timestamp: "", + }); + expect(call!.participants).toEqual([ + { ...participant, audioMuted: true, state: "connected" }, + ]); + expect(joined).toEqual(["ringing"]); + expect(states).toEqual(["connected"]); + + // Lifecycle state is authoritative even when it moves backward during a + // participant reconnect; only stale HTTP invitation replies are ranked. + life.text({ + type: "event", + event: "call.participant_state", + callId: "CALL-1", + payload: { callId: "CALL-1", participant }, + timestamp: "", + }); + expect(call!.participants).toEqual([participant]); + expect(states).toEqual(["connected", "ringing"]); + + life.text({ + type: "event", + event: "call.participant_left", + callId: "CALL-1", + payload: { callId: "CALL-1", participantId: "p1", reason: "hangup" }, + timestamp: "", + }); + expect(call!.participants).toEqual([]); + expect(left).toEqual(["p1"]); + + // Duplicate departures do not notify the application twice. + life.text({ + type: "event", + event: "call.participant_left", + callId: "CALL-1", + payload: { callId: "CALL-1", participantId: "p1" }, + timestamp: "", + }); + expect(call!.participants).toEqual([]); + expect(joined).toEqual(["ringing"]); + expect(left).toEqual(["p1"]); + }); + + it("ignores lifecycle roster events for socket-media calls and ended calls", async () => { + const h = clientWith(); + const life = await connected(h); + let call: Call | undefined; + h.client.on("incoming", (c) => (call = c)); + ring(life); + const event = { + type: "event", + event: "call.participant_joined", + callId: "CALL-1", + payload: { + callId: "CALL-1", + participant: { + id: "p1", + handle: "+15550101", + audioMuted: false, + video: false, + state: "connected", + }, + }, + timestamp: "", + }; + life.text(event); + expect(call!.participants).toEqual([]); + + const external = clientWith(fakeApi(), { mediaMode: "external" }); + const externalLife = await connected(external); + let endedCall: Call | undefined; + external.client.on("incoming", (c) => (endedCall = c)); + ring(externalLife); + externalLife.text({ + type: "event", + event: "call.ended", + callId: "CALL-1", + payload: { reason: "hangup" }, + timestamp: "", + }); + externalLife.text(event); + expect(endedCall!.participants).toEqual([]); + }); + + it("keeps newer lifecycle roster state when an invite reply arrives late", async () => { + const api = fakeApi(); + let resolveInvite: (participant: Participant) => void = () => undefined; + api.addParticipant.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInvite = resolve; + }), + ); + const h = clientWith(api, { mediaMode: "external" }); + const life = await connected(h); + let call: Call | undefined; + h.client.on("incoming", (c) => (call = c)); + ring(life); + const joined: string[] = []; + call!.on("participantJoined", (p) => joined.push(p.state)); + + const adding = call!.addParticipant("+15550102"); + await flush(); + life.text({ + type: "event", + event: "call.participant_joined", + callId: "CALL-1", + payload: { + callId: "CALL-1", + participant: { + id: "p-late", + handle: "+15550102", + audioMuted: false, + video: false, + state: "connected", + }, + }, + timestamp: "", + }); + resolveInvite({ + id: "p-late", + handle: "+15550102", + audioMuted: false, + video: false, + state: "invited", + }); + await adding; + expect(call!.participants.map((p) => p.state)).toEqual(["connected"]); + expect(joined).toEqual(["connected"]); + + life.text({ + type: "event", + event: "call.participant_left", + callId: "CALL-1", + payload: { callId: "CALL-1", participantId: "p-late" }, + timestamp: "", + }); + expect(call!.participants).toEqual([]); + + let resolveDepartedInvite: (participant: Participant) => void = () => + undefined; + api.addParticipant.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDepartedInvite = resolve; + }), + ); + const departedAdding = call!.addParticipant("+15550103"); + await flush(); + const departed = { + id: "p-departed", + handle: "+15550103", + audioMuted: false, + video: false, + state: "connected", + } satisfies Participant; + life.text({ + type: "event", + event: "call.participant_joined", + callId: "CALL-1", + payload: { callId: "CALL-1", participant: departed }, + timestamp: "", + }); + life.text({ + type: "event", + event: "call.participant_left", + callId: "CALL-1", + payload: { callId: "CALL-1", participantId: departed.id }, + timestamp: "", + }); + resolveDepartedInvite({ ...departed, state: "invited" }); + await departedAdding; + expect(call!.participants).toEqual([]); + + api.addParticipant.mockResolvedValueOnce({ ...departed, state: "left" }); + await call!.addParticipant("+15550103"); + expect(call!.participants).toEqual([]); + + // A later explicit reinvite is newer than the departure and may restore it. + api.addParticipant.mockResolvedValueOnce({ ...departed, state: "invited" }); + await call!.addParticipant("+15550103"); + expect(call!.participants).toEqual([{ ...departed, state: "invited" }]); + }); + + it("does not apply an invite reply after the call ends", async () => { + const api = fakeApi(); + let resolveInvite: (participant: Participant) => void = () => undefined; + api.addParticipant.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInvite = resolve; + }), + ); + const h = clientWith(api, { mediaMode: "external" }); + const life = await connected(h); + let call: Call | undefined; + h.client.on("incoming", (c) => (call = c)); + ring(life); + const adding = call!.addParticipant("+15550104"); + await flush(); + life.text({ + type: "event", + event: "call.ended", + callId: "CALL-1", + payload: { reason: "hangup" }, + timestamp: "", + }); + resolveInvite({ + id: "p-ended", + handle: "+15550104", + audioMuted: false, + video: false, + state: "invited", + }); + await adding; + expect(call!.ended).toBe(true); + expect(call!.participants).toEqual([]); + }); + + it("ignores concurrent duplicate invite replies after newer lifecycle state", async () => { + const api = fakeApi(); + const resolveInvites: ((participant: Participant) => void)[] = []; + api.addParticipant.mockImplementation( + () => + new Promise((resolve) => { + resolveInvites.push(resolve); + }), + ); + const h = clientWith(api, { mediaMode: "external" }); + const life = await connected(h); + let call: Call | undefined; + h.client.on("incoming", (c) => (call = c)); + ring(life); + + const first = call!.addParticipant("+15550105"); + const duplicate = call!.addParticipant("+15550105"); + await flush(); + const connectedParticipant = { + id: "p-concurrent", + handle: "+15550105", + audioMuted: false, + video: false, + state: "connected", + } satisfies Participant; + life.text({ + type: "event", + event: "call.participant_state", + callId: "CALL-1", + payload: { callId: "CALL-1", participant: connectedParticipant }, + timestamp: "", + }); + for (const resolve of resolveInvites) + resolve({ ...connectedParticipant, state: "invited" }); + await Promise.all([first, duplicate]); + expect(call!.participants).toEqual([connectedParticipant]); + }); + it("refuses to construct without a credential or an api seam", () => { expect(() => new CallsClient({ session: "s" })).toThrow(/apiKey/); }); @@ -743,3 +1053,230 @@ describe("CallsClient — review round four", () => { h.client.disconnect(); }); }); + +describe("pending placement roster pressure", () => { + it.each([ + [ + "a duplicate departure omits it", + "call.participant_left", + { callId: "CALL-FAST", participantId: "p-1" }, + ], + [ + "a state-left frame follows it", + "call.participant_state", + { + callId: "CALL-FAST", + participant: { + id: "p-1", + handle: "+15550101", + state: "left", + audioMuted: false, + video: false, + }, + }, + ], + [ + "a joined-left frame follows it", + "call.participant_joined", + { + callId: "CALL-FAST", + participant: { + id: "p-1", + handle: "+15550101", + state: "left", + audioMuted: false, + video: false, + }, + }, + ], + ] as const)( + "retains the first departure reason when %s", + async (_, event, payload) => { + const applied: Parameters[0][] = []; + const original = Call.prototype._remoteParticipant; + const participant = vi + .spyOn(Call.prototype, "_remoteParticipant") + .mockImplementation(function (this: Call, frame) { + applied.push(frame); + original.call(this, frame); + }); + const api = fakeApi(); + let resolvePlace!: (value: { callId: string }) => void; + api.place.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePlace = resolve; + }), + ); + const h = clientWith(api, { mediaMode: "external" }); + const life = await connected(h); + const placing = h.client.place("+15550100"); + await flush(); + + life.text({ + type: "event", + event: "call.participant_left", + callId: "CALL-FAST", + timestamp: "", + payload: { + callId: "CALL-FAST", + participantId: "p-1", + reason: "hangup", + }, + }); + life.text({ + type: "event", + event, + callId: "CALL-FAST", + timestamp: "", + payload, + }); + + resolvePlace({ callId: "CALL-FAST" }); + await placing; + expect(applied).toEqual([ + { type: "participant_left", participantId: "p-1", reason: "hangup" }, + ]); + participant.mockRestore(); + }, + ); + + it("evicts the oldest roster entry when a ninth participant arrives", async () => { + const api = fakeApi(); + let resolvePlace!: (value: { callId: string }) => void; + api.place.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePlace = resolve; + }), + ); + const h = clientWith(api, { mediaMode: "external" }); + const life = await connected(h); + const placing = h.client.place("+15550100"); + await flush(); + + for (let i = 0; i < 9; i++) { + life.text({ + type: "event", + event: "call.participant_joined", + callId: "CALL-FAST", + timestamp: "", + payload: { + callId: "CALL-FAST", + participant: { + id: `p-${i}`, + handle: `+1555010${i}`, + state: "connected", + audioMuted: false, + video: false, + }, + }, + }); + } + + resolvePlace({ callId: "CALL-FAST" }); + const call = await placing; + expect(call.participants.map((participant) => participant.id)).toEqual([ + "p-1", + "p-2", + "p-3", + "p-4", + "p-5", + "p-6", + "p-7", + "p-8", + ]); + }); + + it.each(["call.ended", "call.missed", "call.rejected", "call.accepted"])( + "preserves %s after roster bursts", + async (event) => { + const api = fakeApi(); + let resolvePlace!: (value: { callId: string }) => void; + api.place.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePlace = resolve; + }), + ); + const h = clientWith(api, { mediaMode: "external" }); + const life = await connected(h); + const placing = h.client.place("+15550100"); + await flush(); + const roster = (i: number) => + life.text({ + type: "event", + event: "call.participant_joined", + callId: "CALL-FAST", + timestamp: "", + payload: { + callId: "CALL-FAST", + participant: { + id: `p-${i}`, + handle: "+15550101", + state: "connected", + audioMuted: false, + video: false, + }, + }, + }); + for (let i = 0; i < 12; i++) roster(i); + life.text({ + type: "event", + event, + callId: "CALL-FAST", + payload: { reason: "remote_hangup" }, + timestamp: "", + }); + for (let i = 12; i < 24; i++) roster(i); + resolvePlace({ callId: "CALL-FAST" }); + const call = await placing; + if (event === "call.accepted") expect(call.state).toBe("connecting"); + else expect(call.ended).toBe(true); + }, + ); +}); + +describe("participant departure metadata", () => { + it.each([null, 42, {}])( + "retains departure when optional reason is %j", + async (reason) => { + const h = clientWith(fakeApi(), { mediaMode: "external" }); + const life = await connected(h); + let call!: Call; + h.client.on("incoming", (value) => { + call = value; + }); + ring(life); + life.text({ + type: "event", + event: "call.participant_joined", + callId: "CALL-1", + timestamp: "", + payload: { + callId: "CALL-1", + participant: { + id: "p1", + handle: "+15550101", + audioMuted: false, + video: false, + state: "connected", + }, + }, + }); + let departed: string | undefined; + call.on("participantLeft", (id) => { + departed = id; + }); + life.text({ + type: "event", + event: "call.participant_left", + callId: "CALL-1", + timestamp: "", + payload: { callId: "CALL-1", participantId: "p1", reason }, + }); + expect(call.participants).toEqual([]); + expect(departed).toBe("p1"); + }, + ); +}); diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 28f4625..48937c3 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -45,6 +45,10 @@ mint: createMessagingClientTokenMint({ }), ``` +The server key used by either mint path needs all client delegation scopes: +`sessions:manage`, `messages:write`, `contacts:read`, `presence:read`, +`presence:observe`, and `mcp`. + `createTemplateBuilderRoute` pairs the browser template transport with `MessagingClient.templates`. The application authorizes every request and resolves both project scope and the Cloud API submission session on the server. diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 649e7e8..a2a7bf8 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -67,6 +67,12 @@ session rules that authorize them. Use it only on the server. The browser-safe transport and allowed-action resources live in `@polymorfa/browser`; the Next.js-compatible route adapter lives in `@polymorfa/nextjs`. +Minting a client token and updating its session rules require all six client +delegation scopes: `sessions:manage`, `messages:write`, `contacts:read`, +`presence:read`, `presence:observe`, and `mcp`. The same requirement applies to +`MessagingClient.voip.token`; the issuing key must cover every action that the +session rules can delegate to the browser token. + ## Session connection lifecycle Start a Linked Device session, retrieve its JSON QR payload or request a phone @@ -905,8 +911,8 @@ Webhook mutations accept the same `RequestOptions` as every other resource, including idempotency keys, cancellation, timeouts, and API-version overrides. Use `constructWebhookEvent` with the exact raw request bytes before inspecting -an inbound delivery. `isEvent` narrows all 33 event names in the pinned -Messaging contract to their exported payload types: +an inbound delivery. `isEvent` narrows recognized event names to their exported +payload types: ```ts const event = await constructWebhookEvent(rawBody, signature, webhookSecret); @@ -918,6 +924,12 @@ if (isEvent(event, "history.sync")) { } ``` +Development builds also export `CallEndedPayload` and `CallTelemetryPayload`. +For `call.ended`, check `from` before reading its identity: it is `null` when +the media host disappeared before reporting the caller. The reason is +`pod_lost` for those recovered terminal events. Telemetry fields `recvKbps` +and `sendKbps` contain cumulative kilobits, not rates. + The Platform contract exposes campaign events through `PlatformClient.campaigns.events`. It does not expose key-authenticated webhook delivery inspection, replay, test delivery, or a general event list. Console diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 6fd2d7e..997924f 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -535,9 +535,14 @@ export { type BlocklistUpdatePayload, type BusinessQuickReplyUpdatePayload, type CallAcceptedPayload, + type CallEndedPayload, type CallMissedPayload, + type CallParticipant, + type CallParticipantLeftPayload, + type CallParticipantPayload, type CallReceivedPayload, type CallRejectedPayload, + type CallTelemetryPayload, type ChatArchivePayload, type ChatClearPayload, type ChatDeletePayload, diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index 2e590f6..ba5096b 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -2,9 +2,14 @@ export const KNOWN_WEBHOOK_EVENT_TYPES = [ "blocklist.update", "business.quick_reply.update", "call.accepted", + "call.ended", "call.missed", + "call.participant_joined", + "call.participant_left", + "call.participant_state", "call.received", "call.rejected", + "call.telemetry", "chat.archive", "chat.clear", "chat.delete", @@ -253,6 +258,52 @@ export interface CallMissedPayload extends CallReceivedPayload { export type CallAcceptedPayload = CallReceivedPayload; export type CallRejectedPayload = CallReceivedPayload; +export interface CallEndedPayload { + /** Null when the media host disappeared before reporting caller identity. */ + readonly from: JidReference | null; + readonly callId: string; + readonly durationSeconds: number; + /** Includes pod_lost for calls ended after the media host disappears. */ + readonly reason: string; + readonly direction: "inbound" | "outbound"; + readonly hadVideo: boolean; +} + +export interface CallTelemetryPayload { + readonly callId: string; + readonly setupMs: number; + readonly ringMs: number; + readonly durationSeconds: number; + readonly terminateReason: string; + readonly codec: string; + readonly jitterMs: number; + readonly packetsLost: number; + readonly rttMs: number; + /** Cumulative received kilobits, despite the legacy field name. */ + readonly recvKbps: number; + /** Cumulative sent kilobits, despite the legacy field name. */ + readonly sendKbps: number; +} + +export interface CallParticipant { + readonly id: string; + readonly handle: string; + readonly audioMuted: false; + readonly video: false; + readonly state: "invited" | "ringing" | "connected" | "left"; +} + +export interface CallParticipantPayload { + readonly callId: string; + readonly participant: CallParticipant; +} + +export interface CallParticipantLeftPayload { + readonly callId: string; + readonly participantId: string; + readonly reason?: string; +} + export interface NewsletterUpdatePayload { readonly id: string; readonly action: string; @@ -326,9 +377,14 @@ export interface WebhookPayloadMap { readonly "blocklist.update": BlocklistUpdatePayload; readonly "business.quick_reply.update": BusinessQuickReplyUpdatePayload; readonly "call.accepted": CallAcceptedPayload; + readonly "call.ended": CallEndedPayload; readonly "call.missed": CallMissedPayload; + readonly "call.participant_joined": CallParticipantPayload; + readonly "call.participant_left": CallParticipantLeftPayload; + readonly "call.participant_state": CallParticipantPayload; readonly "call.received": CallReceivedPayload; readonly "call.rejected": CallRejectedPayload; + readonly "call.telemetry": CallTelemetryPayload; readonly "chat.archive": ChatArchivePayload; readonly "chat.clear": ChatClearPayload; readonly "chat.delete": ChatDeletePayload; diff --git a/packages/typescript/src/webhooks/index.ts b/packages/typescript/src/webhooks/index.ts index 001fa02..f72c245 100644 --- a/packages/typescript/src/webhooks/index.ts +++ b/packages/typescript/src/webhooks/index.ts @@ -5,9 +5,14 @@ export { type BlocklistUpdatePayload, type BusinessQuickReplyUpdatePayload, type CallAcceptedPayload, + type CallEndedPayload, type CallMissedPayload, + type CallParticipant, + type CallParticipantLeftPayload, + type CallParticipantPayload, type CallReceivedPayload, type CallRejectedPayload, + type CallTelemetryPayload, type ChatArchivePayload, type ChatClearPayload, type ChatDeletePayload, diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts index 7238c8c..5d0c5f8 100644 --- a/packages/typescript/test/coverage-reconciliation.test.ts +++ b/packages/typescript/test/coverage-reconciliation.test.ts @@ -103,7 +103,7 @@ describe("reconciled coverage evidence", () => { }; expect(source.repository).toBe("polymorfa/polymorfa"); // Repinning the reviewed source requires updating this regression gate too. - expect(source.commit).toBe("3bf3a6ba3de8b19a547afd16fca1f3b368d7d4e7"); + expect(source.commit).toBe("8c244aab0e5626d101a2c8c4915287427f39e014"); expect(ledger.sourceCommit).toBe(source.commit); expect(Object.keys(source.contracts).sort()).toEqual([ "messaging", diff --git a/packages/typescript/test/coverage.test.ts b/packages/typescript/test/coverage.test.ts index c7f6081..c9d5bab 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -145,7 +145,7 @@ describe("coverage checker", () => { const result = runRepositoryChecker(); expect(result.status, result.stderr).toBe(0); expect(result.report).toMatchObject({ - sourceCommit: "3bf3a6ba3de8b19a547afd16fca1f3b368d7d4e7", + sourceCommit: "8c244aab0e5626d101a2c8c4915287427f39e014", total: 402, covered: 226, partial: 0, diff --git a/packages/typescript/test/webhook-event-types.test.ts b/packages/typescript/test/webhook-event-types.test.ts index afb47f3..f2a531d 100644 --- a/packages/typescript/test/webhook-event-types.test.ts +++ b/packages/typescript/test/webhook-event-types.test.ts @@ -5,6 +5,9 @@ import type { BusinessQuickReplyUpdatePayload, CallAcceptedPayload, CallMissedPayload, + CallParticipant, + CallParticipantLeftPayload, + CallParticipantPayload, CallReceivedPayload, CallRejectedPayload, ChatArchivePayload, @@ -128,6 +131,31 @@ type ExpectedPayloads = { readonly callId: string; readonly reason: string; }; + readonly "call.participant_joined": { + readonly callId: string; + readonly participant: { + readonly id: string; + readonly handle: string; + readonly audioMuted: false; + readonly video: false; + readonly state: "invited" | "ringing" | "connected" | "left"; + }; + }; + readonly "call.participant_left": { + readonly callId: string; + readonly participantId: string; + readonly reason?: string; + }; + readonly "call.participant_state": { + readonly callId: string; + readonly participant: { + readonly id: string; + readonly handle: string; + readonly audioMuted: false; + readonly video: false; + readonly state: "invited" | "ringing" | "connected" | "left"; + }; + }; readonly "call.received": { readonly from: ExpectedJidReference; readonly callId: string; @@ -257,6 +285,9 @@ type ExportedPayloads = { readonly "business.quick_reply.update": BusinessQuickReplyUpdatePayload; readonly "call.accepted": CallAcceptedPayload; readonly "call.missed": CallMissedPayload; + readonly "call.participant_joined": CallParticipantPayload; + readonly "call.participant_left": CallParticipantLeftPayload; + readonly "call.participant_state": CallParticipantPayload; readonly "call.received": CallReceivedPayload; readonly "call.rejected": CallRejectedPayload; readonly "chat.archive": ChatArchivePayload; @@ -287,6 +318,9 @@ describe("webhook event payload types", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + ExpectedPayloads["call.participant_joined"]["participant"] + >(); expectTypeOf().toEqualTypeOf(); expectTypeOf< Pick diff --git a/packages/typescript/test/webhooks.test.ts b/packages/typescript/test/webhooks.test.ts index 7cfa000..f4f4785 100644 --- a/packages/typescript/test/webhooks.test.ts +++ b/packages/typescript/test/webhooks.test.ts @@ -4,6 +4,9 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { WebhookSignatureError } from "../src/webhooks/verify.js"; import { + KNOWN_WEBHOOK_EVENT_TYPES, + type CallEndedPayload, + type CallTelemetryPayload, constructWebhookEvent, isEvent, verifyWebhookSignature, @@ -60,6 +63,80 @@ describe("constructWebhookEvent", () => { } }); + it.each([null, { id: "15550100@s.whatsapp.net" }])( + "preserves terminal caller identity %j through signature verification", + async (from) => { + const payload: CallEndedPayload = { + from, + callId: "call-555", + durationSeconds: 42, + reason: from === null ? "pod_lost" : "user_hangup", + direction: "outbound", + hadVideo: false, + }; + const body = Buffer.from( + JSON.stringify({ + id: "event-555", + session: "support", + timestamp: "2026-09-07T00:00:00Z", + event: "call.ended", + payload, + }), + ); + const event = await constructWebhookEvent( + body, + sign(body), + "fixture-secret", + ); + expect(KNOWN_WEBHOOK_EVENT_TYPES).toContain("call.ended"); + expect(isEvent(event, "call.ended")).toBe(true); + if (isEvent(event, "call.ended")) { + expectTypeOf(event.payload).toEqualTypeOf(); + expectTypeOf(event.payload.direction).toEqualTypeOf< + "inbound" | "outbound" + >(); + expect(event.payload).toEqual(payload); + expect(event.payload.from?.id ?? null).toBe(from?.id ?? null); + } + }, + ); + + it("exposes terminal telemetry without reinterpreting cumulative kilobits", async () => { + const payload: CallTelemetryPayload = { + callId: "call-555", + setupMs: 300, + ringMs: 2000, + durationSeconds: 42, + terminateReason: "user_hangup", + codec: "opus", + jitterMs: 4, + packetsLost: 2, + rttMs: 35, + recvKbps: 450, + sendKbps: 460, + }; + const body = Buffer.from( + JSON.stringify({ + id: "telemetry-555", + session: "support", + timestamp: "2026-09-07T00:00:00Z", + event: "call.telemetry", + payload, + }), + ); + const event = await constructWebhookEvent( + body, + sign(body), + "fixture-secret", + ); + expect(KNOWN_WEBHOOK_EVENT_TYPES).toContain("call.telemetry"); + expect(isEvent(event, "call.telemetry")).toBe(true); + if (isEvent(event, "call.telemetry")) { + expectTypeOf(event.payload).toEqualTypeOf(); + expect(event.payload).toEqual(payload); + } + }); + it("preserves unknown event names and payloads", async () => { const body = Buffer.from( '{"id":"evt_future","session":"support","timestamp":"2026-08-19T10:00:00Z","event":"future.ready","payload":{"capability":42}}',