From abfdc3419437a263b18350b996b21770cf101be6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 00:22:35 +0300 Subject: [PATCH 01/34] feat(calls)!: add unified call commands Add the calls command family on the unified Calls API: place, accept with an optional exclusive claim or join, decline, leave one connection, end, invite, watch session call updates as JSON Lines, carry call audio as raw PCM, and read or replace session call settings. Socket commands use only the public CallsClient surface of @polymorfa/calls. Client tokens act as their own participant and cannot pass --participant or use call settings. Conflict errors keep their stable code, so call_claimed and call_not_ringing exit with code 4 and are visible in JSON. call reject stays on the Linked Device session route; both reject commands now name each other in their help. Pin @polymorfa/sdk to a65291c and adapt to its contract changes: Customer profiles no longer take a phone number, pairing links no longer take a locale or theme, and widget output shows callback URLs instead of redirect URIs. BREAKING CHANGE: customer create/update drop --phone and --clear-phone, and customer pairing-link create drops --locale and --theme. --- README.md | 90 +- docs/command-research/calls-lids-users.md | 8 +- docs/command-research/calls.md | 126 ++ docs/command-research/customers.md | 11 +- docs/command-research/platform-management.md | 3 + .../specs/2026-08-19-polymorfa-cli-design.md | 34 +- package.json | 11 +- src/base-command.ts | 41 +- src/commands/call/reject.ts | 5 +- src/commands/calls/accept.ts | 51 + src/commands/calls/end.ts | 27 + src/commands/calls/invite.ts | 34 + src/commands/calls/leave.ts | 51 + src/commands/calls/media.ts | 162 +++ src/commands/calls/place.ts | 57 + src/commands/calls/reject.ts | 40 + src/commands/calls/settings/get.ts | 33 + src/commands/calls/settings/set.ts | 43 + src/commands/calls/watch.ts | 87 ++ src/commands/customer/create.ts | 4 - src/commands/customer/pairing-link/create.ts | 14 - src/commands/customer/update.ts | 10 - src/errors/present.ts | 3 + src/output/calls.ts | 72 ++ src/sdk/client.ts | 22 + src/services/calls.ts | 1029 +++++++++++++++++ src/services/customers.ts | 8 +- src/services/platform-management.ts | 4 +- test/commands/calls-sockets.test.ts | 545 +++++++++ test/commands/calls.test.ts | 421 +++++++ test/commands/customers.test.ts | 16 +- test/commands/platform-management.test.ts | 8 +- test/errors/present.test.ts | 19 + test/helpers/calls-server.ts | 238 ++++ 34 files changed, 3247 insertions(+), 80 deletions(-) create mode 100644 docs/command-research/calls.md create mode 100644 src/commands/calls/accept.ts create mode 100644 src/commands/calls/end.ts create mode 100644 src/commands/calls/invite.ts create mode 100644 src/commands/calls/leave.ts create mode 100644 src/commands/calls/media.ts create mode 100644 src/commands/calls/place.ts create mode 100644 src/commands/calls/reject.ts create mode 100644 src/commands/calls/settings/get.ts create mode 100644 src/commands/calls/settings/set.ts create mode 100644 src/commands/calls/watch.ts create mode 100644 src/output/calls.ts create mode 100644 src/services/calls.ts create mode 100644 test/commands/calls-sockets.test.ts create mode 100644 test/commands/calls.test.ts create mode 100644 test/helpers/calls-server.ts diff --git a/README.md b/README.md index 18ff1cb3..c91dd2c4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ access until `@polymorfa/cli` is released publicly. ## Requirements -- Node.js 20 or newer +- Node.js 20 or newer; `calls watch` and `calls media` require Node.js 22 or + newer - A Polymorfa server API key for Platform requests - A server API key or short-lived client token for Messaging requests @@ -73,7 +74,6 @@ polymorfa customer enable \ polymorfa customer create \ --project project_123 \ --name Ada \ - --phone +15551234567 \ --external-id crm_456 \ --idempotency-key create-customer-crm-456 @@ -399,10 +399,90 @@ request. Lists are complete arrays because the source exposes no contact pagination. Block and unblock are reversible actions and accept `--idempotency-key`; they are not available to browser client tokens. -## Calls and stable identities +## Calls + +The `calls` commands place, answer, join, decline, leave, and end calls, invite +participants, follow a session's calls, and carry call audio: + +```shell +polymorfa calls place +15550100 --session support --exclusive +polymorfa calls accept call_123 --exclusive --participant agent-7 +polymorfa calls reject call_123 +polymorfa calls leave call_123 --connection-id conn_0123456789 +polymorfa calls end call_123 +polymorfa calls invite call_123 +15550101 + +polymorfa calls watch --session support --json +polymorfa calls media --session support --place +15550100 \ + --input prompt.pcm --leave-on-eof --output reply.pcm + +polymorfa calls settings get --session support +polymorfa calls settings set --session support --include-self-audio=true +``` + +Every inbound call rings until a participant answers or declines it. The CLI +never answers or declines on its own. + +- **Participants.** A server API key acts as `server:`. Pass + `--participant` (1 to 128 characters from `A-Z`, `a-z`, `0-9`, `.`, `_`, `:`, + `@`, and `-`) to name it; the API uses `default` otherwise. A client token + from `POLYMORFA_CLIENT_TOKEN` acts as its own participant, so the CLI rejects + `--participant` with a client token before sending a request. +- **Sessions.** Server API keys name the session with `--session` for + `calls place`, `calls watch`, and `calls media`. Client tokens use the + session they are bound to; `calls watch` and `calls media` reject + `--session` with a client token. +- **Claims.** `calls accept --exclusive` claims the call. Other participants + then receive `call_claimed`, and the command exits with code 4 and prints + `code: "call_claimed"` under `--json`. Without a claim, a later accept joins + the answered call. `calls reject` exits with code 4 and + `code: "call_not_ringing"` when the call was already answered, declined, or + ended. Declining ends the call for every participant. +- **Connections.** `calls leave` closes one connection, named by an 8 to 64 + character ID from `A-Z`, `a-z`, `0-9`, `_`, and `-`. The call continues. + `calls end` ends the call for everyone. +- **Client token actions.** Client tokens can place, accept, decline, leave, + end, invite, watch, and carry audio when the session's client rules allow + it. The API decides; the CLI reports its errors unchanged. +- **Settings.** `calls settings` uses the Platform API and requires a server + API key. `includeSelfAudio` controls whether the merged audio a connection + receives includes its own audio. It is off by default. + +`calls watch` streams inbound calls and their progress: `incoming`, `claim`, +`state`, `participant_joined`, `participant_state`, `participant_left`, and +`ended`. Human output prints one tab-separated line per update and status on +standard error. `--json` prints one JSON object per line, including `ready`, +`disconnected`, `notice`, and a final `stopped` line. `--count ` exits +after `n` updates; Ctrl-C stops cleanly with exit code 0. Watching reconnects +with backoff after network loss, and exits with code 3 when the API stops +authorizing the credential. It reports calls that ring after it connects, not +calls placed by other participants. + +`calls media` carries one call's audio as raw PCM: signed 16-bit +little-endian, mono, at the call's sample rate (16 kHz by default). With +`--place ` it places a call and connects when it is answered. Without +it, the command waits for an inbound call that rings after it starts (the +given call ID, or the next call), then answers it, or joins it when another +participant answered without a claim. `--input ` sends audio in real +time, `--output ` receives audio (standard output by default, never a +terminal), and `--leave-on-eof` leaves after the input is sent. Status goes to +standard error, as JSON lines with `--json`. Ctrl-C leaves the call, or ends a +placed call that is still ringing. The command carries audio only. + +Convert audio with any tool that writes raw PCM, for example: + +```shell +sox greeting.wav -t raw -r 16000 -e signed -b 16 -c 1 - | \ + polymorfa calls media --session support --place +15550100 \ + --input - --leave-on-eof > reply.pcm +``` + +## Session call rejection and stable identities -Three focused Linked Device commands cover the exact Calls, LIDs, and Users -source tags: +`call reject` is a separate command for the Linked Device session route. It +rejects an incoming call by session, call ID, and caller ID. Use +`calls reject` for the unified Calls API. Three focused Linked Device commands +cover the exact session call rejection, LIDs, and Users source tags: ```shell polymorfa call reject call_123 \ diff --git a/docs/command-research/calls-lids-users.md b/docs/command-research/calls-lids-users.md index f49d46d0..d9bf199a 100644 --- a/docs/command-research/calls-lids-users.md +++ b/docs/command-research/calls-lids-users.md @@ -60,8 +60,12 @@ payload. ## Explicit gaps -The source exposes no call list, retrieve, accept, placement, history, watch, or -stream route. It exposes no bulk LID resolution, identity directory, search, +The unified Calls API is a separate surface with its own command family, +`calls`; see [calls.md](calls.md). `call reject` remains the Linked Device +session route and its help text points to `calls reject`. + +The session route exposes no call list, retrieve, accept, placement, history, +watch, or stream operation. It exposes no bulk LID resolution, identity directory, search, pagination, or retained mapping history. It exposes no security-code list, cache, history, refresh, verification submission, or private QR operation. The CLI does not invent these workflows or cross credential boundaries with raw diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md new file mode 100644 index 00000000..96b4914a --- /dev/null +++ b/docs/command-research/calls.md @@ -0,0 +1,126 @@ +# Calls command research + +- **Status:** Implemented against Calls contract revision 1, unpublished +- **Reviewed:** 2026-09-17 +- **SDK baseline:** `@polymorfa/sdk` and `@polymorfa/calls` at + `polymorfa/sdks` commit `a65291cf6de0574b655affdcab5e6fe44c517b53` + (not yet pushed) + +## Sources reviewed + +- Integrated Calls contract, revision 1 (September 16, 2026), in the monorepo + plan for unified call clients. +- Messaging OpenAPI operations `voipPlaceCall`, `voipAcceptCall`, + `voipRejectCall`, `voipLeaveCall`, `voipTeardown`, and `voipAddParticipant`, + and the Platform operations `getCallSettings` and `updateCallSettings`. +- The API lifecycle socket handler: first-frame authentication, allowed + `session` and `participant` query parameters, 60-second re-validation, and + the 4401, 4409, 4429, 4400, and 1013 close codes. +- The public guide for Calls. +- `MessagingClient.voip` and the public `@polymorfa/calls` entry point + (`CallsClient`, `Call`, and the Calls error classes). + +## Shipped command tree + +```text +calls + place [--session] [--video] [--exclusive] [--participant] + accept [--exclusive] [--video|--no-video] [--participant] + reject [--participant] + leave --connection-id [--participant] + end + invite + watch [--session] [--participant] [--count ] + media [callId] [--session] [--place ] [--exclusive] [--participant] + [--input ] [--output ] [--leave-on-eof] [--video] + settings + get --session + set --session --include-self-audio= +``` + +## Operation mapping + +| Command | Operation | SDK surface | +| -------------------- | ------------------------------------------------ | ----------------------------------- | +| `calls place` | `POST /messaging/voip/calls` | `voip.place` | +| `calls accept` | `POST /messaging/voip/calls/{id}/accept` | `voip.accept` | +| `calls reject` | `POST /messaging/voip/calls/{id}/reject` | `voip.reject` | +| `calls leave` | `POST /messaging/voip/calls/{id}/leave` | `voip.leave` | +| `calls end` | `DELETE /messaging/voip/calls/{id}` | `voip.end` | +| `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` | +| `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` | +| `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` | +| `calls watch` | Lifecycle socket | `CallsClient` events | +| `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` | + +The CLI uses only the neutral public calling surface. It does not expose +offers, candidates, session descriptions, or media framing, and it imports no +internal SDK module. + +## Credential rules + +- Server API keys act as `server:`. `--participant` must match + `[A-Za-z0-9._:@-]{1,128}`; the API defaults to `default`. +- Client tokens (`POLYMORFA_CLIENT_TOKEN`) act as their own participant. The + CLI rejects `--participant` with a client token before any request. The API + enforces client rules (`voip_place`, `voip_answer`, `voip_signal`); the CLI + reports its errors unchanged. +- `calls place`, `calls watch`, and `calls media` require `--session` with a + server API key. Client tokens use their bound session. `calls watch` and + `calls media` reject `--session` with a client token because the socket never + sends it for client tokens and the CLI cannot prove it matches. +- `calls settings` resolves Platform credentials, so client tokens fail with + the standard Platform credential error. The SDK exposes the operation on the + Messaging client; the CLI builds that client from the Platform environment. +- Sockets carry the credential in their first frame. The CLI requires an HTTPS + base URL, or HTTP on a loopback host, before opening any Calls connection. + +## Command decisions + +- **Two reject commands.** `call reject` stays on the Linked Device session + route, which needs the session, call ID, and caller ID. `calls reject` is the + unified API decline. Each command's help names the other. +- **Conflicts.** `call_claimed`, `call_not_ringing`, and `connection_limit` + get specific messages. They exit with code 4, and `--json` errors include the + stable `code`. The CLI adds `code` to presented conflict errors generally. +- **No idempotency flags.** The contract defines no idempotency behavior for + these routes, and the SDK retries keyed POST requests on 409 and 5xx. + Retrying a placement could place a second call, so the CLI does not offer + `--idempotency-key`. `calls end` succeeds when repeated. +- **No confirmation for `calls end`.** Ending a call is the explicit purpose + of the command, like hanging up. Declining with `calls reject` also ends the + call for everyone; both help texts say so. +- **Validation.** Call targets must match the contract pattern + `^(?:\+[1-9]\d{1,14}|[1-9][0-9]{0,18})$`. Connection IDs must match + `^[A-Za-z0-9_-]{8,64}$`. Call IDs must be nonempty. All checks run before + transport. +- **`calls watch`.** The public `CallsClient` reports inbound calls and their + claim, state, roster, and end updates; it does not surface calls placed by + other participants or raw lifecycle frames. The command streams exactly what + the client reports. A 4401 close, or a 4400 refusal, is terminal: exit code 3 + or 2. Other socket problems are printed as `notice` lines while the SDK + reconnects. Ctrl-C and `SIGTERM` stop with exit code 0. +- **`calls media`.** The public client creates calls only from placement or + from a ring it observes, so the command either places a call or waits for an + inbound ring (optionally a specific call ID). It answers the call, or joins + it when another participant answered without a claim. Input is paced in real + time in 20 ms frames because the service buffers only a short window; + an input underrun restarts the clock instead of causing a burst. Received + audio is written as little-endian bytes regardless of host byte order. + Output never goes to a terminal. Ctrl-C leaves, or ends a placed call that is + still ringing. The CLI cannot choose the connection ID because the public + client generates it; status output reports it. +- **Runtime.** Socket commands need a global `WebSocket` (Node.js 22 or + newer). On older runtimes they fail with a configuration error before + connecting. REST commands keep the Node.js 20 floor. + +## Explicit gaps + +- No call list, retrieve, or history command: the public contract has no such + customer operation. +- `calls media` does not attach to a call that was already ringing or answered + before it started, and carries no video. +- `calls watch` does not report outbound calls placed by other participants. +- The `@polymorfa/calls` dependency is not published and cannot be installed + from a Git subdirectory with npm. The CLI cannot release until both SDK + packages are published with compatible semver ranges. diff --git a/docs/command-research/customers.md b/docs/command-research/customers.md index 2c58aef6..36148233 100644 --- a/docs/command-research/customers.md +++ b/docs/command-research/customers.md @@ -9,7 +9,7 @@ explicit project ID. | `customer status` | Check project enablement | | `customer enable` | Enable Customers and assign existing Numbers to the default Customer | | `customer list` | List and filter Customer summaries with cursor metadata | -| `customer create` | Create a Customer with optional name, phone, and external ID | +| `customer create` | Create a Customer with optional name and external ID | | `customer view` | Retrieve one Customer | | `customer update` | Update or clear Customer profile fields | | `customer archive` | Archive a non-default Customer | @@ -23,5 +23,10 @@ explicit project ID. The CLI preserves `Idempotency-Key` headers for every operation that requires them. It also keeps list cursor metadata in JSON output. Human output masks -phone numbers returned by list and Number endpoints; full Customer retrieval -returns the API response unchanged under `--json`. +phone numbers returned by Number endpoints; full Customer retrieval returns the +API response unchanged under `--json`. + +Customer profiles carry no phone number, and pairing-link creation accepts no +page locale or theme. The pinned contract removed those fields, so the CLI no +longer offers `--phone`, `--clear-phone`, `--locale`, or `--theme`. Pairing +pages take appearance from saved QuickLink settings. diff --git a/docs/command-research/platform-management.md b/docs/command-research/platform-management.md index be1d9bc7..0f72461d 100644 --- a/docs/command-research/platform-management.md +++ b/docs/command-research/platform-management.md @@ -104,6 +104,9 @@ its mutation target. A missing saved row remains `null` in JSON and has explicit human output. Empty patches remain representable because bodyless PUT is a live compatibility no-op. Logo fields are storage identifiers only; the CLI does not invent file, URL, base64, multipart, upload, attach, or download behavior. +Human output shows the success and failure callback URLs and whether +recipients can change a prefilled phone number. Those fields replaced the +redirect URI allowlist in the pinned contract. Batch session commands use positional IDs because they are the primary action targets. The CLI trims and validates 1..100 unique IDs before transport instead diff --git a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md index 1284624d..34688f04 100644 --- a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md +++ b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md @@ -144,8 +144,13 @@ retry, pagination, or error behavior. Its command-research record must identify the exact raw operation and track promotion to a curated SDK method when the workflow benefits from one. -The CLI depends only on the root server package. Browser, React, Next.js, UI, -Elements, and Devtools SDK workspaces are not CLI runtime dependencies. +The CLI depends on the root server package and on `@polymorfa/calls`, the +programmatic Calls client, for the call event stream and call audio. It uses +only the public, neutral entry point of `@polymorfa/calls`, never its internal +module. Browser, React, Next.js, UI, Elements, and Devtools SDK workspaces are +not CLI runtime dependencies. npm cannot install a Git subdirectory, so +`@polymorfa/calls` resolves only after it is published; until then development +installs unpack the packed workspace locally. ## Internal boundaries @@ -542,6 +547,19 @@ business call reject +calls + place + accept + reject + leave + end + invite + watch + media + settings + get + set + lid resolve @@ -706,7 +724,14 @@ phone number, stable ID, deprecated LID alias, or username and optional four- digit key. Security-code lookup requires a stable `digits@lid` user ID; human output favors the 60-digit code and does not dump display-QR base64, while JSON retains the complete no-store response. No command claims bulk identity lookup, -call history/acceptance/streaming, or security-code verification submission. +call history, or security-code verification submission through that session +route. The unified Calls API is a separate `calls` family: place, accept (with +an optional exclusive claim) or join, decline, leave one connection, end, +invite, watch session call updates as JSON Lines, carry call audio as raw PCM, +and read or replace session call settings. It never exposes offers, +candidates, or media framing. Client tokens act as their own participant and +cannot name `--participant` or use call settings; server API keys name the +session and may name a participant. See `docs/command-research/calls.md`. Commands for contacts, chat history, and logs state whether data comes from opt-in hosted storage, live session state, or another documented source. Empty @@ -1049,6 +1074,9 @@ The private `dev` branch now contains working, unpublished vertical slices: collection, order, compliance, linked-account, and eligibility commands; - complete incoming-call rejection, singular LID resolution, and no-store user security-code inspection commands; +- unified Calls commands for placement, answering and joining with exclusive + claims, declining, leaving, ending, inviting, session call updates, raw PCM + call audio, and session call settings, pinned to an unpushed SDK revision; - organization-key API-key list/revocation, member and audit lists, session-ban filtering, security-incident list/acknowledgement, operation inspection, and project-token metadata commands; diff --git a/package.json b/package.json index 13d74106..dd39e00c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#08452992a32a392de5fc3d0fa3954ba6addd7f32" + "@polymorfa/calls": "0.1.0-dev.0", + "@polymorfa/sdk": "github:polymorfa/sdks#a65291cf6de0574b655affdcab5e6fe44c517b53" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -92,7 +93,13 @@ "description": "Manage the business profile cover photo." }, "call": { - "description": "Manage incoming Linked Device calls." + "description": "Reject incoming calls through the Linked Device session route." + }, + "calls": { + "description": "Place, answer, join, decline, leave, end, and watch calls, and carry call audio." + }, + "calls:settings": { + "description": "Inspect and replace session call settings." }, "campaign": { "description": "Inspect and manage Messaging campaigns." diff --git a/src/base-command.ts b/src/base-command.ts index cf5e601f..772e5b47 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -5,7 +5,10 @@ import { type Client as PlatformClient, } from '@polymorfa/sdk' -import {resolveCommandEnvironment} from './context/environment.js' +import { + resolveCommandEnvironment, + type ResolvedEnvironment, +} from './context/environment.js' import {presentError} from './errors/present.js' import {renderValue, type OutputMode} from './output/render.js' import {createMessagingClient, createPlatformClient} from './sdk/client.js' @@ -77,15 +80,7 @@ export abstract class BaseCommand extends Command { flags: GlobalFlags, options: {readonly serverApiKeyOnly?: string} = {}, ): Promise { - const environment = await resolveCommandEnvironment({ - env: process.env, - surface: 'messaging', - ...(flags.profile === undefined ? {} : {profile: flags.profile}), - ...(flags['api-version'] === undefined - ? {} - : {apiVersion: flags['api-version']}), - ...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}), - }) + const environment = await this.messagingEnvironment(flags) if ( options.serverApiKeyOnly !== undefined && environment.credential.type !== 'apiKey' @@ -95,14 +90,34 @@ export abstract class BaseCommand extends Command { 'credential', ) } - this.registerSecret(environment.credential.value) return createMessagingClient(environment) } + /** Resolve Messaging credentials once, for commands that also open sockets. */ + protected async messagingEnvironment( + flags: GlobalFlags, + ): Promise { + return this.resolveEnvironment(flags, 'messaging') + } + protected async platformClient(flags: GlobalFlags): Promise { + return createPlatformClient(await this.platformEnvironment(flags)) + } + + /** Resolve Platform credentials: server API keys only. */ + protected async platformEnvironment( + flags: GlobalFlags, + ): Promise { + return this.resolveEnvironment(flags, 'platform') + } + + private async resolveEnvironment( + flags: GlobalFlags, + surface: ResolvedEnvironment['surface'], + ): Promise { const environment = await resolveCommandEnvironment({ env: process.env, - surface: 'platform', + surface, ...(flags.profile === undefined ? {} : {profile: flags.profile}), ...(flags['api-version'] === undefined ? {} @@ -110,7 +125,7 @@ export abstract class BaseCommand extends Command { ...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}), }) this.registerSecret(environment.credential.value) - return createPlatformClient(environment) + return environment } protected registerSecret(secret: string): void { diff --git a/src/commands/call/reject.ts b/src/commands/call/reject.ts index 9eb91e67..97e186db 100644 --- a/src/commands/call/reject.ts +++ b/src/commands/call/reject.ts @@ -11,7 +11,10 @@ export default class CallReject extends BaseCommand { call: Args.string({description: 'Incoming call ID.', required: true}), } - static override description = 'Reject an incoming Linked Device call.' + static override description = + 'Reject an incoming call through the Linked Device session route, identified by session, call ID, and caller ID. Requires a server API key. To decline a call on the unified Calls API, use calls reject.' + + static override summary = 'Reject an incoming call by session and caller.' static override flags = { from: Flags.string({ diff --git a/src/commands/calls/accept.ts b/src/commands/calls/accept.ts new file mode 100644 index 00000000..3369b7ef --- /dev/null +++ b/src/commands/calls/accept.ts @@ -0,0 +1,51 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {acceptCall, renderAcceptedCall} from '../../services/calls.js' + +export default class CallsAccept extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + } + + static override description = + 'Answer a ringing call, or join a call another participant answered without a claim. --exclusive claims the call: other participants then get call_claimed and their connections close. Exits with code 4 when another participant claimed the call (call_claimed). Accepting does not attach media; use calls media or a browser client for that.' + + static override examples = [ + '<%= config.bin %> calls accept call_123', + '<%= config.bin %> calls accept call_123 --exclusive --participant agent-7 --json', + ] + + static override flags = { + exclusive: Flags.boolean({ + description: 'Claim the call so no other participant can answer or join.', + }), + participant: Flags.string({ + description: + 'Server API keys only: participant that answers (default "default").', + }), + video: Flags.boolean({ + allowNo: true, + description: 'Answer with video, or --no-video for audio only.', + }), + } + + static override summary = 'Answer or join a call.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsAccept) + const environment = await this.messagingEnvironment(flags) + const result = await acceptCall( + createMessagingClient(environment), + environment.credential, + args.callId, + { + exclusive: flags.exclusive, + participant: flags.participant, + video: flags.video, + }, + ) + this.writeValue(result, renderAcceptedCall(result.data), flags) + } +} diff --git a/src/commands/calls/end.ts b/src/commands/calls/end.ts new file mode 100644 index 00000000..be73f0cd --- /dev/null +++ b/src/commands/calls/end.ts @@ -0,0 +1,27 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {endCall} from '../../services/calls.js' + +export default class CallsEnd extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + } + + static override description = + 'End a call for every participant and close every connection. Repeating the command succeeds.' + + static override examples = ['<%= config.bin %> calls end call_123'] + + static override summary = 'End a call for everyone.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsEnd) + const result = await endCall( + createMessagingClient(await this.messagingEnvironment(flags)), + args.callId, + ) + this.writeValue(result, `Ended call ${args.callId}.`, flags) + } +} diff --git a/src/commands/calls/invite.ts b/src/commands/calls/invite.ts new file mode 100644 index 00000000..b47ca60a --- /dev/null +++ b/src/commands/calls/invite.ts @@ -0,0 +1,34 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {inviteToCall, renderCallParticipant} from '../../services/calls.js' + +export default class CallsInvite extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + target: Args.string({ + description: 'Phone number in E.164 form or a numeric WhatsApp user ID.', + required: true, + }), + } + + static override description = + 'Ring another WhatsApp user into a call, turning a 1:1 call into a group call. The call must have a live leg.' + + static override examples = [ + '<%= config.bin %> calls invite call_123 +15550101', + ] + + static override summary = 'Invite a participant into a call.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsInvite) + const result = await inviteToCall( + createMessagingClient(await this.messagingEnvironment(flags)), + args.callId, + args.target, + ) + this.writeValue(result, renderCallParticipant(result.data), flags) + } +} diff --git a/src/commands/calls/leave.ts b/src/commands/calls/leave.ts new file mode 100644 index 00000000..f87cb609 --- /dev/null +++ b/src/commands/calls/leave.ts @@ -0,0 +1,51 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {leaveCall} from '../../services/calls.js' + +export default class CallsLeave extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + } + + static override description = + 'Close one media connection. The call and the participant’s other connections continue. Use calls end to end the call for everyone.' + + static override examples = [ + '<%= config.bin %> calls leave call_123 --connection-id conn_0123456789', + ] + + static override flags = { + 'connection-id': Flags.string({ + description: + 'Connection to close: 8 to 64 letters, digits, underscores, or hyphens.', + required: true, + }), + participant: Flags.string({ + description: + 'Server API keys only: participant that owns the connection (default "default").', + }), + } + + static override summary = 'Close one call connection.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsLeave) + const environment = await this.messagingEnvironment(flags) + const result = await leaveCall( + createMessagingClient(environment), + environment.credential, + args.callId, + { + connectionId: flags['connection-id'], + participant: flags.participant, + }, + ) + this.writeValue( + result, + `Closed connection ${flags['connection-id']} on call ${args.callId}.`, + flags, + ) + } +} diff --git a/src/commands/calls/media.ts b/src/commands/calls/media.ts new file mode 100644 index 00000000..b4a763e5 --- /dev/null +++ b/src/commands/calls/media.ts @@ -0,0 +1,162 @@ +import {createReadStream, createWriteStream, type WriteStream} from 'node:fs' +import {once} from 'node:events' + +import {Args, Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +import {BaseCommand} from '../../base-command.js' +import {bridgeCallMedia, type CallMediaStatus} from '../../services/calls.js' +import { + renderCallActivity, + renderCallsStatus, + stopOnSignals, +} from '../../output/calls.js' + +export default class CallsMedia extends BaseCommand { + static override args = { + callId: Args.string({ + description: + 'Inbound call to answer when it rings. Omit it to answer the next inbound call.', + }), + } + + static override description = [ + 'Carry one call’s audio as raw PCM (signed 16-bit little-endian, mono, at the sample rate the call reports; 16 kHz by default).', + 'Without --place, the command waits for an inbound call that rings after it starts, then answers it, or joins it when another participant answered without a claim.', + 'Audio from --input is sent in real time; received audio is written to --output. Status goes to standard error, as JSON lines with --json.', + 'Ctrl-C leaves the call, or ends a call you placed that is still ringing. Exits with code 4 when another participant claimed the call.', + ].join(' ') + + static override examples = [ + '<%= config.bin %> calls media --session support --output call.pcm', + '<%= config.bin %> calls media --session support --place +15550100 --input prompt.pcm --leave-on-eof --output reply.pcm', + 'sox in.wav -t raw -r 16000 -e signed -b 16 -c 1 - | <%= config.bin %> calls media call_123 --session support --input - --exclusive > out.pcm', + ] + + static override flags = { + exclusive: Flags.boolean({ + description: + 'Claim the call so no other participant can answer or join it.', + }), + input: Flags.string({ + description: 'Raw PCM file to send, or - for standard input.', + }), + 'leave-on-eof': Flags.boolean({ + dependsOn: ['input'], + description: 'Leave the call after all input audio is sent.', + }), + output: Flags.string({ + default: '-', + description: 'File for received PCM, or - for standard output.', + }), + participant: Flags.string({ + description: + 'Server API keys only: participant that carries the audio (default "default").', + }), + place: Flags.string({ + description: + 'Place a call to this E.164 number or user ID instead of answering one.', + }), + session: Flags.string({ + description: + 'Session of the call. Required with a server API key; client tokens use their bound session.', + }), + video: Flags.boolean({ + allowNo: true, + description: + 'Request video for the call. This command carries audio only.', + }), + } + + static override summary = 'Send and receive call audio as raw PCM.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsMedia) + if (flags.output === '-' && process.stdout.isTTY) { + throw new PolymorfaConfigurationError( + 'Received audio is binary. Redirect standard output or pass --output .', + 'output', + ) + } + if (flags.input === '-' && process.stdin.isTTY) { + throw new PolymorfaConfigurationError( + 'Pipe raw PCM into standard input or pass --input .', + 'input', + ) + } + const environment = await this.messagingEnvironment(flags) + const json = this.outputMode(flags) === 'json' + const quiet = this.outputMode(flags) === 'quiet' + const report = (value: object, human: string): void => { + if (json) process.stderr.write(`${JSON.stringify(value)}\n`) + else if (!quiet) process.stderr.write(`${human}\n`) + } + + const file: WriteStream | undefined = + flags.output === '-' ? undefined : createWriteStream(flags.output) + const output = (pcm: Uint8Array): void => { + if (file === undefined) process.stdout.write(pcm) + else file.write(pcm) + } + const input = + flags.input === undefined + ? undefined + : flags.input === '-' + ? process.stdin + : createReadStream(flags.input) + + const stop = stopOnSignals() + try { + const summary = await bridgeCallMedia( + { + baseUrl: environment.baseUrl, + callId: args.callId, + credential: environment.credential, + exclusive: flags.exclusive, + input, + leaveOnEnd: flags['leave-on-eof'], + output, + participant: flags.participant, + place: flags.place, + session: flags.session, + signal: stop.signal, + video: flags.video, + }, + (status) => report(status, renderMediaStatus(status, flags.session)), + ) + report( + {type: 'finished', ...summary}, + `Finished (${summary.reason}): received ${summary.bytesReceived} bytes, sent ${summary.bytesSent} bytes.`, + ) + } finally { + stop.dispose() + if (input !== undefined && input !== process.stdin) input.destroy() + if (input === process.stdin) process.stdin.pause() + if (file !== undefined) { + file.end() + await once(file, 'close') + } + } + } +} + +function renderMediaStatus( + status: CallMediaStatus, + session: string | undefined, +): string { + switch (status.type) { + case 'ready': + return `${renderCallsStatus(status, session)} Waiting for the call.` + case 'disconnected': + case 'notice': + return renderCallsStatus(status, session) + case 'connected': + return `Connected to call ${status.callId} as connection ${status.connectionId} at ${status.sampleRate} Hz.` + case 'placed': + return `Placed call ${status.callId}; waiting for an answer.` + case 'input_ended': + return `Sent all input audio (${status.bytes} bytes).` + default: + return renderCallActivity(status) + } +} diff --git a/src/commands/calls/place.ts b/src/commands/calls/place.ts new file mode 100644 index 00000000..b7d8066a --- /dev/null +++ b/src/commands/calls/place.ts @@ -0,0 +1,57 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {placeCall, renderPlacedCall} from '../../services/calls.js' + +export default class CallsPlace extends BaseCommand { + static override args = { + to: Args.string({ + description: 'Phone number in E.164 form or a numeric WhatsApp user ID.', + required: true, + }), + } + + static override description = + 'Place a WhatsApp call from a session. The placing participant owns the call. Server API keys name the session with --session; client tokens place from the session they are bound to.' + + static override examples = [ + '<%= config.bin %> calls place +15550100 --session support', + '<%= config.bin %> calls place +15550100 --session support --video --exclusive --participant agent-7 --json', + ] + + static override flags = { + exclusive: Flags.boolean({ + description: + 'Claim the call for the placing participant; other participants cannot join it.', + }), + participant: Flags.string({ + description: + 'Server API keys only: participant that places the call (default "default").', + }), + session: Flags.string({ + description: + 'Session that places the call. Required with a server API key.', + }), + video: Flags.boolean({description: 'Place a video call.'}), + } + + static override summary = 'Place a call.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsPlace) + const environment = await this.messagingEnvironment(flags) + const result = await placeCall( + createMessagingClient(environment), + environment.credential, + { + exclusive: flags.exclusive, + participant: flags.participant, + session: flags.session, + to: args.to, + video: flags.video, + }, + ) + this.writeValue(result, renderPlacedCall(result.data), flags) + } +} diff --git a/src/commands/calls/reject.ts b/src/commands/calls/reject.ts new file mode 100644 index 00000000..3c2321c2 --- /dev/null +++ b/src/commands/calls/reject.ts @@ -0,0 +1,40 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {declineCall} from '../../services/calls.js' + +export default class CallsReject extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + } + + static override description = + 'Decline a ringing call through the unified Calls API. Declining ends the call for every participant. Exits with code 4 when the call is no longer ringing (call_not_ringing). To reject a call through the Linked Device session route with the caller ID, use call reject.' + + static override examples = [ + '<%= config.bin %> calls reject call_123', + '<%= config.bin %> calls reject call_123 --participant agent-7 --json', + ] + + static override flags = { + participant: Flags.string({ + description: + 'Server API keys only: participant that declines (default "default").', + }), + } + + static override summary = 'Decline a ringing call.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsReject) + const environment = await this.messagingEnvironment(flags) + const result = await declineCall( + createMessagingClient(environment), + environment.credential, + args.callId, + {participant: flags.participant}, + ) + this.writeValue(result, `Declined call ${args.callId}.`, flags) + } +} diff --git a/src/commands/calls/settings/get.ts b/src/commands/calls/settings/get.ts new file mode 100644 index 00000000..9a961b4a --- /dev/null +++ b/src/commands/calls/settings/get.ts @@ -0,0 +1,33 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createCallSettingsClient} from '../../../sdk/client.js' +import {renderCallSettings, viewCallSettings} from '../../../services/calls.js' + +export default class CallsSettingsGet extends BaseCommand { + static override description = + 'Read a session’s call settings from the Platform API. Sessions without saved settings return the defaults. Requires a server API key.' + + static override examples = [ + '<%= config.bin %> calls settings get --session support', + ] + + static override flags = { + session: Flags.string({description: 'Session ID.', required: true}), + } + + static override summary = 'Read session call settings.' + + async run(): Promise { + const {flags} = await this.parse(CallsSettingsGet) + const result = await viewCallSettings( + createCallSettingsClient(await this.platformEnvironment(flags)), + flags.session, + ) + this.writeValue( + result, + renderCallSettings(flags.session, result.data), + flags, + ) + } +} diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts new file mode 100644 index 00000000..b26d40e9 --- /dev/null +++ b/src/commands/calls/settings/set.ts @@ -0,0 +1,43 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createCallSettingsClient} from '../../../sdk/client.js' +import { + renderCallSettings, + updateCallSettings, +} from '../../../services/calls.js' + +export default class CallsSettingsSet extends BaseCommand { + static override description = + 'Replace a session’s call settings through the Platform API. includeSelfAudio controls whether the merged audio a connection receives includes its own audio (off by default). WhatsApp never hears its own audio. Requires a server API key.' + + static override examples = [ + '<%= config.bin %> calls settings set --session support --include-self-audio=true', + ] + + static override flags = { + 'include-self-audio': Flags.string({ + description: + 'Include each connection’s own audio in the merged audio it receives.', + options: ['true', 'false'], + required: true, + }), + session: Flags.string({description: 'Session ID.', required: true}), + } + + static override summary = 'Replace session call settings.' + + async run(): Promise { + const {flags} = await this.parse(CallsSettingsSet) + const result = await updateCallSettings( + createCallSettingsClient(await this.platformEnvironment(flags)), + flags.session, + flags['include-self-audio'] === 'true', + ) + this.writeValue( + result, + renderCallSettings(flags.session, result.data), + flags, + ) + } +} diff --git a/src/commands/calls/watch.ts b/src/commands/calls/watch.ts new file mode 100644 index 00000000..4bcd1bbe --- /dev/null +++ b/src/commands/calls/watch.ts @@ -0,0 +1,87 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + watchCalls, + type CallActivity, + type CallsStatus, +} from '../../services/calls.js' +import { + renderCallActivity, + renderCallsStatus, + stopOnSignals, +} from '../../output/calls.js' + +export default class CallsWatch extends BaseCommand { + static override description = + 'Stream a session’s inbound calls and their progress: ringing, claims, state and participant changes, and endings. Watching never answers or declines a call. With --json, each line is one JSON object. Press Ctrl-C to stop. Exits with code 3 when the API stops authorizing the credential.' + + static override examples = [ + '<%= config.bin %> calls watch --session support', + '<%= config.bin %> calls watch --session support --participant agent-7 --json', + 'POLYMORFA_CLIENT_TOKEN=... <%= config.bin %> calls watch --count 1 --json', + ] + + static override flags = { + count: Flags.integer({ + description: 'Exit after this many call updates.', + min: 1, + }), + participant: Flags.string({ + description: + 'Server API keys only: participant that watches (default "default").', + }), + session: Flags.string({ + description: + 'Session to watch. Required with a server API key; client tokens watch their bound session.', + }), + } + + static override summary = 'Stream call updates for a session.' + + async run(): Promise { + const {flags} = await this.parse(CallsWatch) + const environment = await this.messagingEnvironment(flags) + const mode = this.outputMode(flags) + const line = ( + value: CallActivity | CallsStatus | object, + human: string, + ) => { + if (mode === 'json') process.stdout.write(`${JSON.stringify(value)}\n`) + else if (mode === 'human') process.stdout.write(`${human}\n`) + } + const status = (value: CallsStatus): void => { + if (mode === 'json') line(value, '') + else if (mode === 'human') { + process.stderr.write(`${renderCallsStatus(value, flags.session)}\n`) + } + } + + const stop = stopOnSignals() + try { + const summary = await watchCalls( + { + baseUrl: environment.baseUrl, + count: flags.count, + credential: environment.credential, + participant: flags.participant, + session: flags.session, + signal: stop.signal, + }, + { + activity: (activity) => + line( + {...activity, at: new Date().toISOString()}, + renderCallActivity(activity), + ), + status, + }, + ) + if (mode === 'json') { + line({type: 'stopped', ...summary}, '') + } + } finally { + stop.dispose() + } + } +} diff --git a/src/commands/customer/create.ts b/src/commands/customer/create.ts index 0117eb6d..348e8f14 100644 --- a/src/commands/customer/create.ts +++ b/src/commands/customer/create.ts @@ -17,9 +17,6 @@ export default class CustomerCreate extends BaseCommand { required: true, }), name: Flags.string({description: 'Customer name.'}), - phone: Flags.string({ - description: 'Customer phone number in E.164 format.', - }), } async run(): Promise { @@ -29,7 +26,6 @@ export default class CustomerCreate extends BaseCommand { { projectId: requireCustomerProject(flags.project), ...(flags.name === undefined ? {} : {name: flags.name}), - ...(flags.phone === undefined ? {} : {phone: flags.phone}), ...(flags['external-id'] === undefined ? {} : {externalCustomerId: flags['external-id']}), diff --git a/src/commands/customer/pairing-link/create.ts b/src/commands/customer/pairing-link/create.ts index ad5402e4..a48a8f94 100644 --- a/src/commands/customer/pairing-link/create.ts +++ b/src/commands/customer/pairing-link/create.ts @@ -27,19 +27,11 @@ export default class CustomerPairingLinkCreate extends BaseCommand { description: 'Identify this pairing-link attempt for safe retries.', required: true, }), - locale: Flags.string({ - description: 'Pairing page locale.', - options: ['en', 'pt-BR'], - }), method: Flags.string({ description: 'Allowed pairing method.', multiple: true, options: ['qr', 'phone'], }), - theme: Flags.string({ - description: 'Pairing page theme.', - options: ['light', 'dark', 'system'], - }), } async run(): Promise { @@ -55,15 +47,9 @@ export default class CustomerPairingLinkCreate extends BaseCommand { ...(flags['expires-in'] === undefined ? {} : {expiresInSeconds: flags['expires-in']}), - ...(flags.locale === undefined - ? {} - : {locale: flags.locale as 'en' | 'pt-BR'}), ...(flags.method === undefined ? {} : {methods: flags.method as Array<'qr' | 'phone'>}), - ...(flags.theme === undefined - ? {} - : {theme: flags.theme as 'light' | 'dark' | 'system'}), }, {idempotencyKey: flags['idempotency-key']}, ) diff --git a/src/commands/customer/update.ts b/src/commands/customer/update.ts index 2db3f0c7..016b2696 100644 --- a/src/commands/customer/update.ts +++ b/src/commands/customer/update.ts @@ -23,17 +23,10 @@ export default class CustomerUpdate extends BaseCommand { description: 'Clear the Customer name.', exclusive: ['name'], }), - 'clear-phone': Flags.boolean({ - description: 'Clear the Customer phone number.', - exclusive: ['phone'], - }), 'external-id': Flags.string({ description: 'New caller-defined Customer ID.', }), name: Flags.string({description: 'New Customer name.'}), - phone: Flags.string({ - description: 'New Customer phone number in E.164 format.', - }), } async run(): Promise { @@ -46,9 +39,6 @@ export default class CustomerUpdate extends BaseCommand { ...(flags.name === undefined && flags['clear-name'] !== true ? {} : {name: flags.name ?? null}), - ...(flags.phone === undefined && flags['clear-phone'] !== true - ? {} - : {phone: flags.phone ?? null}), ...(flags['external-id'] === undefined && flags['clear-external-id'] !== true ? {} diff --git a/src/errors/present.ts b/src/errors/present.ts index 66a0da09..2e7c228e 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -57,6 +57,9 @@ export function presentError( : {code: redact(error.code, secrets)}), } : {}), + ...(error instanceof PolymorfaConflictError && error.code !== undefined + ? {code: redact(error.code, secrets)} + : {}), message, ...(requestId === undefined ? {} : {requestId}), } diff --git a/src/output/calls.ts b/src/output/calls.ts new file mode 100644 index 00000000..f5a3ef0a --- /dev/null +++ b/src/output/calls.ts @@ -0,0 +1,72 @@ +import type {CallActivity, CallsStatus} from '../services/calls.js' + +/** An abort signal for Ctrl-C and SIGTERM, removed when the command ends. */ +export function stopOnSignals(): { + readonly dispose: () => void + readonly signal: AbortSignal +} { + const controller = new AbortController() + const stop = (): void => controller.abort() + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + return { + dispose: () => { + process.off('SIGINT', stop) + process.off('SIGTERM', stop) + }, + signal: controller.signal, + } +} + +export function renderCallsStatus( + status: CallsStatus, + session: string | undefined, +): string { + switch (status.type) { + case 'ready': + return `Following calls${session === undefined ? '' : ` for session ${session}`}${status.participant === undefined ? '' : ` as ${status.participant}`}. Press Ctrl-C to stop.` + case 'disconnected': + return 'Disconnected from calls; reconnecting.' + case 'notice': + return `Warning: ${status.message} (${status.code})` + } +} + +export function renderCallActivity(activity: CallActivity): string { + const fields: string[] = [activity.type, activity.callId] + switch (activity.type) { + case 'incoming': + fields.push( + `from=${activity.peer || 'unknown'}`, + `video=${activity.video}`, + ) + break + case 'claim': + fields.push( + `answered=${activity.answered}`, + `answeredBy=${activity.answeredBy ?? ''}`, + `exclusive=${activity.exclusive}`, + `claimedByOther=${activity.claimedByOther}`, + ) + break + case 'state': + fields.push(`${activity.previous}->${activity.state}`) + break + case 'participant_joined': + case 'participant_state': + fields.push( + `participant=${activity.participant.id}`, + `state=${activity.participant.state}`, + ) + break + case 'participant_left': + fields.push(`participant=${activity.participantId}`) + if (activity.reason !== undefined) + fields.push(`reason=${activity.reason}`) + break + case 'ended': + fields.push(`reason=${activity.reason}`, `duration=${activity.duration}s`) + break + } + return fields.join('\t') +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts index eb012617..37575177 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -30,6 +30,28 @@ export function createMessagingClient( }) } +/** + * Session call settings live on the Platform API, but the SDK exposes them on + * the Messaging client's Calls resource. Build that client from a resolved + * Platform environment so the Platform credential rules (server API keys + * only) still apply. + */ +export function createCallSettingsClient( + environment: ResolvedEnvironment, +): MessagingClient { + if (environment.surface !== 'platform') { + throw new PolymorfaConfigurationError( + 'Session call settings require the Platform API surface.', + 'surface', + ) + } + + return new MessagingClient({ + credential: {type: 'apiKey', value: environment.credential.value}, + ...sharedOptions(environment), + }) +} + export function createPlatformClient( environment: ResolvedEnvironment, ): PlatformClient { diff --git a/src/services/calls.ts b/src/services/calls.ts new file mode 100644 index 00000000..a13a5dd8 --- /dev/null +++ b/src/services/calls.ts @@ -0,0 +1,1029 @@ +import {randomBytes} from 'node:crypto' + +import { + CallClaimedError, + CallsApiError, + CallsAuthError, + CallsClient, + CallsError, + type Call, + type CallClaim, + type CallEndReason, + type CallState, +} from '@polymorfa/calls' +import { + PolymorfaAuthenticationError, + PolymorfaAuthorizationError, + PolymorfaConfigurationError, + PolymorfaConflictError, + PolymorfaConnectionError, + PolymorfaError, + PolymorfaNotFoundError, + PolymorfaRateLimitError, + PolymorfaServerError, + PolymorfaValidationError, + type MessagingClient, + type MessagingCredential, + type ResponseMetadata, + type SessionCallSettings, + type SuccessResponse, + type VoipAcceptCallResult, + type VoipParticipant, + type VoipPlaceCallResult, +} from '@polymorfa/sdk' + +/** Unified Calls credential facts the commands need before any transport. */ +export interface CallsCredential { + readonly type: MessagingCredential['type'] + readonly value: string +} + +export interface CallsResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +const PARTICIPANT_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/ +const CONNECTION_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/ +const CALL_TARGET_PATTERN = /^(?:\+[1-9]\d{1,14}|[1-9]\d{0,18})$/ +const DEFAULT_BASE_URL = 'https://api.polymorfa.com' + +// ── Validation ───────────────────────────────────────────────────────────── + +export function validateCallId(callId: string): string { + if (callId.trim().length === 0) { + throw new PolymorfaConfigurationError('Call ID cannot be empty.', 'call') + } + return callId +} + +export function validateCallTarget(value: string, field = 'to'): string { + const target = value.trim() + if (!CALL_TARGET_PATTERN.test(target)) { + throw new PolymorfaConfigurationError( + 'Call target must be an E.164 phone number such as +15550100 or a numeric user ID.', + field, + ) + } + return target +} + +export function validateConnectionId(value: string): string { + if (!CONNECTION_ID_PATTERN.test(value)) { + throw new PolymorfaConfigurationError( + 'Connection ID must be 8 to 64 letters, digits, underscores, or hyphens.', + 'connectionId', + ) + } + return value +} + +/** + * Server credentials may name the acting participant. Client tokens always act + * as their own `client:` participant. + */ +export function validateParticipant( + credential: CallsCredential, + participant: string | undefined, +): string | undefined { + if (participant === undefined) return undefined + if (credential.type === 'clientToken') { + throw new PolymorfaConfigurationError( + 'Client tokens act as their own participant; --participant requires a server API key.', + 'participant', + ) + } + if (!PARTICIPANT_PATTERN.test(participant)) { + throw new PolymorfaConfigurationError( + 'Participant must be 1 to 128 letters, digits, or . _ : @ - characters.', + 'participant', + ) + } + return participant +} + +function requireServerSession( + credential: CallsCredential, + session: string | undefined, + action: string, +): string | undefined { + const trimmed = session?.trim() + if (trimmed !== undefined && trimmed.length === 0) { + throw new PolymorfaConfigurationError('Session cannot be empty.', 'session') + } + if (credential.type !== 'clientToken' && trimmed === undefined) { + throw new PolymorfaConfigurationError( + `${action} with a server API key requires --session. Client tokens use the session they are bound to.`, + 'session', + ) + } + return trimmed +} + +// ── REST operations ──────────────────────────────────────────────────────── + +export interface PlaceCallInput { + readonly exclusive?: boolean | undefined + readonly participant?: string | undefined + readonly session?: string | undefined + readonly to: string + readonly video?: boolean | undefined +} + +export async function placeCall( + client: MessagingClient, + credential: CallsCredential, + input: PlaceCallInput, +): Promise> { + const to = validateCallTarget(input.to) + const session = requireServerSession( + credential, + input.session, + 'Placing a call', + ) + const participant = validateParticipant(credential, input.participant) + const response = await callRequest(() => + client.voip.place({ + to, + ...(session === undefined ? {} : {session}), + ...(input.video === undefined ? {} : {video: input.video}), + ...(input.exclusive === undefined ? {} : {exclusive: input.exclusive}), + ...(participant === undefined ? {} : {participant}), + }), + ) + return result(response.data.data, response.metadata) +} + +export interface AcceptCallInput { + readonly exclusive?: boolean | undefined + readonly participant?: string | undefined + readonly video?: boolean | undefined +} + +export async function acceptCall( + client: MessagingClient, + credential: CallsCredential, + callId: string, + input: AcceptCallInput = {}, +): Promise> { + validateCallId(callId) + const participant = validateParticipant(credential, input.participant) + const response = await callRequest(() => + client.voip.accept(callId, { + ...(input.exclusive === undefined ? {} : {exclusive: input.exclusive}), + ...(input.video === undefined ? {} : {video: input.video}), + ...(participant === undefined ? {} : {participant}), + }), + ) + return result(response.data.data, response.metadata) +} + +export async function declineCall( + client: MessagingClient, + credential: CallsCredential, + callId: string, + input: {readonly participant?: string | undefined} = {}, +): Promise> { + validateCallId(callId) + const participant = validateParticipant(credential, input.participant) + const response = await callRequest(() => + client.voip.reject(callId, participant === undefined ? {} : {participant}), + ) + return result(response.data, response.metadata) +} + +export async function leaveCall( + client: MessagingClient, + credential: CallsCredential, + callId: string, + input: { + readonly connectionId: string + readonly participant?: string | undefined + }, +): Promise> { + validateCallId(callId) + const connectionId = validateConnectionId(input.connectionId) + const participant = validateParticipant(credential, input.participant) + const response = await callRequest(() => + client.voip.leave(callId, { + connectionId, + ...(participant === undefined ? {} : {participant}), + }), + ) + return result(response.data, response.metadata) +} + +export async function endCall( + client: MessagingClient, + callId: string, +): Promise> { + validateCallId(callId) + const response = await callRequest(() => client.voip.end(callId)) + return result(response.data, response.metadata) +} + +export async function inviteToCall( + client: MessagingClient, + callId: string, + target: string, +): Promise> { + validateCallId(callId) + const to = validateCallTarget(target, 'target') + const response = await callRequest(() => + client.voip.addParticipant(callId, {to}), + ) + return result(response.data.data, response.metadata) +} + +export async function viewCallSettings( + client: MessagingClient, + session: string, +): Promise> { + const response = await client.voip.retrieveCallSettings( + validateSession(session), + ) + return result(response.data.data, response.metadata) +} + +export async function updateCallSettings( + client: MessagingClient, + session: string, + includeSelfAudio: boolean, +): Promise> { + const response = await client.voip.updateCallSettings( + validateSession(session), + {includeSelfAudio}, + ) + return result(response.data.data, response.metadata) +} + +function validateSession(session: string): string { + const trimmed = session.trim() + if (trimmed.length === 0 || trimmed.length > 128) { + throw new PolymorfaConfigurationError( + 'Session must contain 1 to 128 characters.', + 'session', + ) + } + return trimmed +} + +/** Replace the API's generic conflict text with the Calls meaning of its code. */ +async function callRequest(run: () => Promise): Promise { + try { + return await run() + } catch (error) { + if (error instanceof PolymorfaConflictError) { + const message = conflictMessages[error.code ?? ''] + if (message !== undefined) { + throw new PolymorfaConflictError(message, { + cause: error, + ...(error.code === undefined ? {} : {code: error.code}), + ...(error.status === undefined ? {} : {status: error.status}), + ...(error.requestId === undefined + ? {} + : {requestId: error.requestId}), + ...(error.metadata === undefined ? {} : {metadata: error.metadata}), + details: error.details, + }) + } + } + throw error + } +} + +const conflictMessages: Readonly> = { + call_claimed: + 'Another participant claimed this call (call_claimed). It cannot be answered or joined; do not decline it.', + call_not_ringing: + 'The call is not ringing (call_not_ringing): it was already answered, declined, or ended.', + connection_limit: + 'The call has no free connection slot (connection_limit). Leave another connection first.', +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +export function renderPlacedCall(call: VoipPlaceCallResult): string { + return [ + `Call\t${call.callId}`, + `Session\t${call.session}`, + `Video\t${yesNo(call.video)}`, + 'The call is ringing. Watch it with polymorfa calls watch.', + ].join('\n') +} + +export function renderAcceptedCall(result: VoipAcceptCallResult): string { + return [ + `Answered\t${yesNo(result.answered)}`, + `Answered by\t${result.answeredBy}`, + `Exclusive\t${yesNo(result.exclusive)}`, + ].join('\n') +} + +export function renderCallParticipant(participant: VoipParticipant): string { + return [ + `Participant\t${participant.id}`, + participant.phoneNumber === undefined + ? undefined + : `Phone\t${participant.phoneNumber}`, + participant.username === undefined + ? undefined + : `Username\t${participant.username}`, + `State\t${participant.state}`, + ] + .filter((value): value is string => value !== undefined) + .join('\n') +} + +export function renderCallSettings( + session: string, + settings: SessionCallSettings, +): string { + return [ + `Session\t${session}`, + `Include self audio\t${yesNo(settings.includeSelfAudio)}`, + `Updated\t${settings.updatedAt ?? 'never (defaults)'}`, + ].join('\n') +} + +function yesNo(value: boolean): string { + return value ? 'yes' : 'no' +} + +function result(data: T, metadata: ResponseMetadata): CallsResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} + +// ── Calls client: watch and media ────────────────────────────────────────── + +export interface CallsClientInput { + readonly baseUrl?: string | undefined + readonly credential: CallsCredential + readonly participant?: string | undefined + readonly session?: string | undefined + /** Injected for runtimes without a global WebSocket, and for tests. */ + readonly WebSocket?: typeof globalThis.WebSocket | undefined +} + +/** A new media connection ID: 18 random bytes as base64url. */ +export function createCallConnectionId(): string { + return randomBytes(18).toString('base64url') +} + +/** + * Sockets and REST calls made by the Calls client carry the credential, so + * apply the SDK's transport rule: HTTPS, or HTTP only on a loopback host. + */ +export function assertCallsBaseUrl(baseUrl: string): string { + let url: URL + try { + url = new URL(baseUrl) + } catch { + throw new PolymorfaConfigurationError( + 'POLYMORFA_BASE_URL must be an absolute HTTP or HTTPS URL.', + 'baseUrl', + ) + } + if (url.username !== '' || url.password !== '') { + throw new PolymorfaConfigurationError( + 'POLYMORFA_BASE_URL must not contain credentials.', + 'baseUrl', + ) + } + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new PolymorfaConfigurationError( + 'Calls require an HTTPS POLYMORFA_BASE_URL, or HTTP on a loopback host.', + 'baseUrl', + ) + } + return baseUrl +} + +function createCallsClient( + input: CallsClientInput, + action: string, +): CallsClient { + const session = requireServerSession(input.credential, input.session, action) + if (input.credential.type === 'clientToken' && session !== undefined) { + throw new PolymorfaConfigurationError( + `${action} with a client token uses the session the token is bound to; omit --session.`, + 'session', + ) + } + const participant = validateParticipant(input.credential, input.participant) + const WebSocketImpl = input.WebSocket ?? globalThis.WebSocket + if (WebSocketImpl === undefined) { + throw new PolymorfaConfigurationError( + 'Calls require Node.js 22 or later, which provides WebSocket.', + 'runtime', + ) + } + return new CallsClient({ + token: input.credential.value, + // Client tokens are bound to one session and never send it. + session: session ?? '', + baseUrl: assertCallsBaseUrl(input.baseUrl ?? DEFAULT_BASE_URL), + WebSocket: WebSocketImpl, + ...(participant === undefined ? {} : {participant}), + }) +} + +/** Translate a Calls client failure into the CLI's SDK error categories. */ +export function callsClientError( + error: unknown, + refusal?: string, +): PolymorfaError { + if (error instanceof PolymorfaError) return error + if (error instanceof CallClaimedError) { + return new PolymorfaConflictError(conflictMessages.call_claimed!, { + code: 'call_claimed', + status: 409, + cause: error, + }) + } + if (error instanceof CallsApiError) { + const options = {code: error.code, status: error.status, cause: error} + const message = conflictMessages[error.code] ?? error.message + if (error.status === 400 || error.status === 422) + return new PolymorfaValidationError(message, options) + if (error.status === 401) + return new PolymorfaAuthenticationError(message, options) + if (error.status === 403) + return new PolymorfaAuthorizationError(message, options) + if (error.status === 404) + return new PolymorfaNotFoundError(message, options) + if (error.status === 409) + return new PolymorfaConflictError(message, options) + if (error.status === 429) + return new PolymorfaRateLimitError(message, options) + return new PolymorfaServerError(message, options) + } + if (error instanceof CallsAuthError) { + return new PolymorfaAuthenticationError( + `The credential does not authorize this session${refusal === undefined ? '' : ` (${refusal})`}. Check the credential, its client rules, and the session.`, + {code: refusal ?? error.code, cause: error}, + ) + } + if (error instanceof CallsError) { + if (error.code === 'invalid_request') { + return new PolymorfaValidationError( + 'The API refused the calls connection. Check the session and participant.', + {code: error.code, cause: error}, + ) + } + return new PolymorfaConnectionError(error.message, { + code: error.code, + cause: error, + }) + } + return new PolymorfaConnectionError( + error instanceof Error ? error.message : String(error), + error instanceof Error ? {cause: error} : {}, + ) +} + +const unreachable: CallsStatus = { + type: 'notice', + code: 'connect_failed', + message: + 'Could not connect to the calls service; retrying with backoff. Press Ctrl-C to stop.', +} + +function isTerminalClientError(error: CallsError): boolean { + return error instanceof CallsAuthError || error.code === 'invalid_request' +} + +export type CallActivity = + | { + readonly type: 'incoming' + readonly callId: string + readonly peer: string + readonly video: boolean + } + | ({readonly type: 'claim'; readonly callId: string} & CallClaim) + | { + readonly type: 'state' + readonly callId: string + readonly state: CallState + readonly previous: CallState + } + | { + readonly type: 'participant_joined' | 'participant_state' + readonly callId: string + readonly participant: VoipParticipant + } + | { + readonly type: 'participant_left' + readonly callId: string + readonly participantId: string + readonly reason?: string + } + | { + readonly type: 'ended' + readonly callId: string + readonly reason: CallEndReason + readonly duration: number + } + +export type CallsStatus = + | {readonly type: 'ready'; readonly participant?: string} + | {readonly type: 'disconnected'} + | {readonly type: 'notice'; readonly code: string; readonly message: string} + +/** Report a call's claim, state, and roster changes until it ends. */ +function followCall( + call: Call, + activity: (activity: CallActivity) => void, +): void { + call.on('claim', (claim) => + activity({type: 'claim', callId: call.id, ...claim}), + ) + call.on('state', (state, previous) => { + if (state !== 'ended') { + activity({type: 'state', callId: call.id, state, previous}) + } + }) + call.on('participantJoined', (participant) => + activity({type: 'participant_joined', callId: call.id, participant}), + ) + call.on('participantState', (participant) => + activity({type: 'participant_state', callId: call.id, participant}), + ) + call.on('participantLeft', (participantId, reason) => + activity({ + type: 'participant_left', + callId: call.id, + participantId, + ...(reason === undefined ? {} : {reason}), + }), + ) +} + +export interface WatchCallsInput extends CallsClientInput { + /** Stop after this many call activities. */ + readonly count?: number | undefined + readonly signal: AbortSignal +} + +export interface WatchCallsSummary { + readonly activities: number + readonly stoppedBy: 'count' | 'signal' +} + +/** + * Follow a session's calls with the Calls client. The client authenticates, + * reconnects with backoff, and never answers or declines on its own. This + * resolves on the abort signal or `count` and rejects on a terminal refusal. + */ +export async function watchCalls( + input: WatchCallsInput, + handlers: { + readonly activity: (activity: CallActivity) => void + readonly status: (status: CallsStatus) => void + }, +): Promise { + const client = createCallsClient(input, 'Watching calls') + + return new Promise((resolve, reject) => { + let activities = 0 + let finished = false + const finish = (outcome: 'count' | 'signal' | PolymorfaError): void => { + if (finished) return + finished = true + input.signal.removeEventListener('abort', onAbort) + void client.disconnect().finally(() => { + if (outcome instanceof PolymorfaError) reject(outcome) + else resolve({activities, stoppedBy: outcome}) + }) + } + const onAbort = (): void => finish('signal') + const report = (activity: CallActivity): void => { + if (finished) return + handlers.activity(activity) + activities += 1 + if (input.count !== undefined && activities >= input.count) { + finish('count') + } + } + + client.on('ready', () => { + if (finished) return + const participant = client.participantReference + handlers.status({ + type: 'ready', + ...(participant === undefined ? {} : {participant}), + }) + }) + client.on('disconnected', () => { + if (!finished) handlers.status({type: 'disconnected'}) + }) + let refusal: string | undefined + client.on('ready', () => { + refusal = undefined + }) + client.on('error', (error) => { + if (finished) return + if (isTerminalClientError(error)) { + finish(callsClientError(error, refusal)) + return + } + if (!client.connected) refusal = error.code + handlers.status({ + type: 'notice', + code: error.code, + message: error.message, + }) + }) + client.on('incoming', (call) => { + report({ + type: 'incoming', + callId: call.id, + peer: call.peer, + video: call.hasVideo, + }) + followCall(call, report) + }) + client.on('ended', (call, reason) => + report({type: 'ended', callId: call.id, reason, duration: call.duration}), + ) + + if (input.signal.aborted) { + finish('signal') + return + } + input.signal.addEventListener('abort', onAbort, {once: true}) + client.connect().then( + () => { + if (!finished && !client.connected) handlers.status(unreachable) + }, + (error: unknown) => finish(callsClientError(error)), + ) + }) +} + +export interface CallMediaInput extends CallsClientInput { + /** Answer this inbound call; without it, the next inbound call. */ + readonly callId?: string | undefined + readonly exclusive?: boolean | undefined + /** Raw s16le mono PCM to send, paced in real time. */ + readonly input?: AsyncIterable | undefined + /** Leave the call once `input` is exhausted and sent. */ + readonly leaveOnEnd?: boolean | undefined + readonly now?: (() => number) | undefined + readonly output: (pcm: Uint8Array) => void + /** Place a call to this target instead of answering one. */ + readonly place?: string | undefined + readonly signal: AbortSignal + readonly video?: boolean | undefined +} + +export type CallMediaStatus = + | CallsStatus + | CallActivity + | { + readonly type: 'connected' + readonly callId: string + readonly connectionId: string + readonly sampleRate: number + } + | {readonly type: 'placed'; readonly callId: string} + | {readonly type: 'input_ended'; readonly bytes: number} + +export interface CallMediaSummary { + readonly bytesReceived: number + readonly bytesSent: number + readonly callId?: string + readonly connectionId?: string + readonly reason: CallEndReason | 'cancelled' +} + +const FRAME_MS = 20 +const MAX_BUFFERED_MS = 2_000 +const FAILED_ENDINGS = new Set([ + 'capacity', + 'connection_failed', + 'pod_lost', +]) + +/** + * Carry one call's audio as raw PCM: place a call, or answer (or join) an + * inbound one, then send `input` and deliver received audio to `output`. + */ +export async function bridgeCallMedia( + input: CallMediaInput, + status: (status: CallMediaStatus) => void, +): Promise { + if (input.place !== undefined && input.callId !== undefined) { + throw new PolymorfaConfigurationError( + 'Choose a call ID to answer or --place, not both.', + 'call', + ) + } + const target = + input.place === undefined ? undefined : validateCallTarget(input.place) + if (input.callId !== undefined) validateCallId(input.callId) + const client = createCallsClient(input, 'Carrying call media') + + let bytesReceived = 0 + let bytesSent = 0 + let active: Call | undefined + let finished = false + let resolveDone: (value: CallMediaSummary) => void = () => undefined + let rejectDone: (error: PolymorfaError) => void = () => undefined + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + const summary = (reason: CallMediaSummary['reason']): CallMediaSummary => ({ + bytesReceived, + bytesSent, + reason, + ...(active === undefined + ? {} + : {callId: active.id, connectionId: active.connectionId}), + }) + const finish = ( + outcome: CallMediaSummary['reason'] | PolymorfaError, + ): void => { + if (finished) return + finished = true + if (outcome instanceof PolymorfaError) rejectDone(outcome) + else resolveDone(summary(outcome)) + } + let lastCallError: CallsError | undefined + /** Stop our part: end a call still ringing that we placed, else leave. */ + const hangUp = (call: Call): void => { + const stop = + call.direction === 'outbound' && call.state === 'ringing' + ? call.end() + : call.leave() + stop.then( + () => finish(call.endReason ?? 'left'), + (error: unknown) => finish(callsClientError(error)), + ) + } + + const attach = (call: Call): void => { + active = call + followCall(call, status) + call.on('error', (error) => { + lastCallError = error + if (!(error instanceof CallClaimedError)) { + status({type: 'notice', code: error.code, message: error.message}) + } + }) + call.once('ended', (reason) => { + status({ + type: 'ended', + callId: call.id, + reason, + duration: call.duration, + }) + if (reason === 'claimed') { + finish(callsClientError(new CallClaimedError())) + } else if (FAILED_ENDINGS.has(reason)) { + finish( + lastCallError === undefined + ? new PolymorfaConnectionError( + `The call media connection failed (${reason}).`, + {code: reason}, + ) + : callsClientError(lastCallError), + ) + } else { + finish(reason) + } + }) + call.audio.on('data', (pcm) => { + const bytes = new Uint8Array(pcm.length * 2) + const view = new DataView(bytes.buffer) + for (let index = 0; index < pcm.length; index += 1) { + view.setInt16(index * 2, pcm[index] ?? 0, true) + } + bytesReceived += bytes.byteLength + input.output(bytes) + }) + call.once('connected', () => { + status({ + type: 'connected', + callId: call.id, + connectionId: call.connectionId, + sampleRate: call.audio.sampleRate, + }) + if (input.input === undefined) return + pacePcm({ + input: input.input, + isClosed: () => finished || call.ended, + now: input.now ?? Date.now, + sampleRate: () => call.audio.sampleRate, + write: (pcm) => { + if (call.audio.write(pcm)) bytesSent += pcm.length * 2 + }, + }).then( + (bytes) => { + if (finished || call.ended) return + status({type: 'input_ended', bytes}) + if (input.leaveOnEnd === true) hangUp(call) + }, + (error: unknown) => { + status({ + type: 'notice', + code: 'input_failed', + message: error instanceof Error ? error.message : String(error), + }) + hangUp(call) + }, + ) + }) + } + + const answer = (call: Call): void => { + attach(call) + const joining = call.claim.canJoin && input.exclusive !== true + const accepting = joining + ? call.join(input.video === undefined ? {} : {video: input.video}) + : call.answer({ + exclusive: input.exclusive === true, + ...(input.video === undefined ? {} : {video: input.video}), + }) + accepting.catch((error: unknown) => finish(callsClientError(error))) + } + + client.on('ready', () => { + refusal = undefined + const participant = client.participantReference + status({ + type: 'ready', + ...(participant === undefined ? {} : {participant}), + }) + }) + client.on('disconnected', () => { + if (!finished) status({type: 'disconnected'}) + }) + let refusal: string | undefined + client.on('error', (error) => { + if (finished) return + if (isTerminalClientError(error)) { + finish(callsClientError(error, refusal)) + return + } + if (!client.connected) refusal = error.code + status({type: 'notice', code: error.code, message: error.message}) + }) + if (target === undefined) { + client.on('incoming', (call) => { + if (active !== undefined) return + if (input.callId !== undefined && call.id !== input.callId) return + status({ + type: 'incoming', + callId: call.id, + peer: call.peer, + video: call.hasVideo, + }) + answer(call) + }) + } + + const onAbort = (): void => { + if (active === undefined || active.ended) finish('cancelled') + else hangUp(active) + } + if (input.signal.aborted) return summary('cancelled') + input.signal.addEventListener('abort', onAbort, {once: true}) + + try { + await client.connect() + if (!finished && !client.connected) status(unreachable) + if (target !== undefined && !finished) { + try { + const call = await client.place(target, { + exclusive: input.exclusive === true, + ...(input.video === undefined ? {} : {video: input.video}), + }) + status({type: 'placed', callId: call.id}) + attach(call) + } catch (error) { + finish(callsClientError(error)) + } + } + return await done + } finally { + input.signal.removeEventListener('abort', onAbort) + await client.disconnect() + } +} + +interface PaceInput { + readonly input: AsyncIterable + readonly isClosed: () => boolean + readonly now: () => number + readonly sampleRate: () => number + readonly write: (pcm: Int16Array) => void +} + +/** + * Send PCM no faster than real time, in 20 ms frames. The service buffers + * only a short window, so unpaced file input would be dropped. An input + * underrun restarts the clock instead of causing a later burst. + */ +async function pacePcm(options: PaceInput): Promise { + let pending = new Uint8Array(0) + let ended = false + let total = 0 + let failure: Error | undefined + let wake: (() => void) | undefined + const notify = (): void => { + const current = wake + wake = undefined + current?.() + } + const sleep = (): Promise => + new Promise((resolve) => setTimeout(resolve, FRAME_MS)) + + void (async () => { + try { + for await (const chunk of options.input) { + if (options.isClosed()) break + const next = new Uint8Array(pending.byteLength + chunk.byteLength) + next.set(pending) + next.set(chunk, pending.byteLength) + pending = next + total += chunk.byteLength + notify() + const limit = bytesFor(options.sampleRate(), MAX_BUFFERED_MS) + while (pending.byteLength > limit && !options.isClosed()) await sleep() + } + } catch (error) { + failure = + error instanceof Error ? error : new Error('Audio input failed.') + } finally { + ended = true + notify() + } + })() + + let started: number | undefined + let consumed = 0 + while (!options.isClosed()) { + const available = Math.floor(pending.byteLength / 2) + if (available === 0) { + if (failure !== undefined) throw failure + if (ended) break + await new Promise((resolve) => { + wake = resolve + }) + continue + } + const now = options.now() + if (started === undefined) { + started = now + consumed = 0 + } + const rate = options.sampleRate() + const due = + Math.floor(((now - started) * rate) / 1000) + + samplesFor(rate, FRAME_MS) - + consumed + if (due > 0) { + const count = Math.min(due, available) + const view = new DataView(pending.buffer, pending.byteOffset, count * 2) + const pcm = new Int16Array(count) + for (let index = 0; index < count; index += 1) { + pcm[index] = view.getInt16(index * 2, true) + } + pending = pending.slice(count * 2) + options.write(pcm) + consumed += count + if (count < due) started = undefined + } + await sleep() + } + if (failure !== undefined) throw failure + return total - (pending.byteLength % 2) +} + +function samplesFor(sampleRate: number, milliseconds: number): number { + return Math.max(1, Math.floor((sampleRate * milliseconds) / 1000)) +} + +function bytesFor(sampleRate: number, milliseconds: number): number { + return samplesFor(sampleRate, milliseconds) * 2 +} diff --git a/src/services/customers.ts b/src/services/customers.ts index 55504f2c..bf2681a1 100644 --- a/src/services/customers.ts +++ b/src/services/customers.ts @@ -89,11 +89,7 @@ export async function updateCustomer( customerId: string, body: UpdateCustomerRequest, ): Promise> { - if ( - body.name === undefined && - body.phone === undefined && - body.externalCustomerId === undefined - ) { + if (body.name === undefined && body.externalCustomerId === undefined) { throw new PolymorfaValidationError( 'Provide at least one Customer profile field to update.', {code: 'missing_customer_update'}, @@ -204,7 +200,6 @@ export function renderCustomers(customers: CustomerListEnvelope): string { [ customer.id, customer.name ?? '', - customer.phoneMasked ?? '', customer.status, `${customer.connectedNumberCount}/${customer.numberCount} connected`, customer.needsAttention ? 'needs attention' : '', @@ -217,7 +212,6 @@ export function renderCustomer(customer: Customer): string { return [ `ID\t${customer.id}`, `Name\t${customer.name ?? ''}`, - `Phone\t${customer.phone ?? ''}`, `External ID\t${customer.externalCustomerId ?? ''}`, `Status\t${customer.status}`, `Default\t${customer.isDefault}`, diff --git a/src/services/platform-management.ts b/src/services/platform-management.ts index 162419c6..f8ae731d 100644 --- a/src/services/platform-management.ts +++ b/src/services/platform-management.ts @@ -248,7 +248,9 @@ export function renderWidgetSettings( `Methods\t${settings.methods?.join(', ') ?? 'default'}`, `Theme\t${settings.theme}`, `Business name\t${settings.businessName ?? ''}`, - `Redirect URIs\t${settings.allowedRedirectUris.join(', ')}`, + `Success callback\t${settings.successCallbackUrl ?? ''}`, + `Failure callback\t${settings.failureCallbackUrl ?? ''}`, + `Allow phone change\t${settings.allowPhoneChange}`, `Hide watermark\t${settings.hideWatermark}`, `Shape\t${settings.shape ?? 'default'}`, `Radius\t${settings.radiusPx ?? 'default'}`, diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts new file mode 100644 index 00000000..d56eb3db --- /dev/null +++ b/test/commands/calls-sockets.test.ts @@ -0,0 +1,545 @@ +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {Readable} from 'node:stream' + +import {runCommand} from '@oclif/test' +import {afterEach, beforeEach, describe, expect, test} from 'vitest' + +import {bridgeCallMedia, watchCalls} from '../../src/services/calls.js' +import { + startCallsServer, + type CallsServer, + type FakeSocket, +} from '../helpers/calls-server.js' +import {withEnvironment} from '../helpers/http-server.js' + +const API_KEY = 'pmfa_socket_test_key_0123456789' +const CLIENT_TOKEN = 'pmfa_ct_socket_test_token' +const TIMESTAMP = '2026-09-16T10:00:00.000Z' + +describe('calls watch and media', () => { + let server: CallsServer | undefined + let directory: string + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-calls-')) + }) + + afterEach(async () => { + await server?.close() + server = undefined + await rm(directory, {force: true, recursive: true}) + }) + + test('streams call updates as JSON lines after first-frame authentication', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({ + participant: 'server:agent-7', + session: 'support', + type: 'ready', + }) + sendEvent(socket, 'call.received', 'call_1', { + from: {phoneNumber: '+15550100'}, + hasVideo: true, + }) + sendEvent(socket, 'call.accepted', 'call_1', { + answeredBy: 'client:web-1', + exclusive: true, + }) + sendEvent(socket, 'call.participant_joined', 'call_1', { + participant: { + audioMuted: false, + id: 'participant_1', + state: 'connected', + video: false, + }, + }) + sendEvent(socket, 'call.ended', 'call_1', {reason: 'user_hangup'}) + }, + }) + + const result = await apiKey(() => + runCommand( + 'calls watch --session support --participant agent-7 --count 4 --json', + ), + ) + + expect(result.error).toBeUndefined() + const lines = result.stdout + .trim() + .split('\n') + .map((line) => withoutTime(JSON.parse(line) as Record)) + expect(lines).toEqual([ + {participant: 'server:agent-7', type: 'ready'}, + {callId: 'call_1', peer: '+15550100', type: 'incoming', video: true}, + { + answered: true, + answeredBy: 'client:web-1', + callId: 'call_1', + canJoin: false, + claimedByOther: true, + exclusive: true, + type: 'claim', + }, + { + callId: 'call_1', + participant: { + audioMuted: false, + id: 'participant_1', + state: 'connected', + video: false, + }, + type: 'participant_joined', + }, + {callId: 'call_1', duration: 0, reason: 'remote_hangup', type: 'ended'}, + {activities: 4, stoppedBy: 'count', type: 'stopped'}, + ]) + const [socket] = server.sockets + expect(socket?.path).toBe('/voip/ws?session=support&participant=agent-7') + expect(socket?.path).not.toContain(API_KEY) + expect(JSON.parse(String(socket?.messages[0]))).toEqual({ + token: API_KEY, + type: 'auth', + }) + }) + + test('prints readable call updates and status in human mode', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_1', { + from: '+15550100', + hasVideo: false, + }) + }, + }) + + const result = await apiKey(() => + runCommand('calls watch --session support --count 1'), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toBe( + 'incoming\tcall_1\tfrom=+15550100\tvideo=false\n', + ) + expect(result.stderr).toContain( + 'Following calls for session support as server:default.', + ) + }) + + test('exits with an authentication error when the socket is refused with 4401', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({ + code: 'permission_denied', + message: 'credential not accepted', + type: 'error', + }) + socket.close(4401, 'unauthorized') + }, + }) + + const result = await apiKey(() => + runCommand('calls watch --session support --json'), + ) + + expect(exitCode(result.error)).toBe(3) + expect(JSON.parse(result.stderr)).toMatchObject({ + category: 'authentication', + exitCode: 3, + }) + expect(result.stderr).toContain('permission_denied') + expect(result.stderr).not.toContain(API_KEY) + expect(server.sockets).toHaveLength(1) + }) + + test('watches a client token’s bound session without query parameters', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({ + participant: 'client:web-1', + session: 'support', + type: 'ready', + }) + sendEvent(socket, 'call.received', 'call_2', {from: '+15550100'}) + }, + }) + + const result = await clientToken(() => + runCommand('calls watch --count 1 --json'), + ) + + expect(result.error).toBeUndefined() + expect(server.sockets[0]?.path).toBe('/voip/ws') + expect(JSON.parse(String(server.sockets[0]?.messages[0]))).toEqual({ + token: CLIENT_TOKEN, + type: 'auth', + }) + }) + + test('validates watch credentials, session, and base URL before connecting', async () => { + server = await startCallsServer({}) + + const participant = await clientToken(() => + runCommand('calls watch --participant agent-7 --json'), + ) + const session = await clientToken(() => + runCommand('calls watch --session support --json'), + ) + const missing = await apiKey(() => runCommand('calls watch --json')) + const insecure = await withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: 'http://api.example.com', + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand('calls watch --session support --json'), + ) + + expect(exitCode(participant.error)).toBe(2) + expect(participant.stderr).toContain( + '--participant requires a server API key', + ) + expect(exitCode(session.error)).toBe(2) + expect(session.stderr).toContain('omit --session') + expect(exitCode(missing.error)).toBe(2) + expect(missing.stderr).toContain('requires --session') + expect(exitCode(insecure.error)).toBe(2) + expect(insecure.stderr).toContain('HTTPS POLYMORFA_BASE_URL') + expect(server.sockets).toHaveLength(0) + }) + + test('stops watching cleanly when interrupted', async () => { + const controller = new AbortController() + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({participant: 'server:default', type: 'ready'}) + }, + }) + + const summary = await watchCalls( + { + baseUrl: server.baseUrl, + credential: {type: 'apiKey', value: API_KEY}, + session: 'support', + signal: controller.signal, + }, + { + activity: () => undefined, + status: (status) => { + if (status.type === 'ready') controller.abort() + }, + }, + ) + + expect(summary).toEqual({activities: 0, stoppedBy: 'signal'}) + expect(await server.sockets[0]?.closed).toBeDefined() + }) + + test('reports an unreachable calls service while it keeps retrying', async () => { + const controller = new AbortController() + server = await startCallsServer({}) + const baseUrl = server.baseUrl + await server.close() + server = undefined + const statuses: string[] = [] + + const summary = await watchCalls( + { + baseUrl, + credential: {type: 'apiKey', value: API_KEY}, + session: 'support', + signal: controller.signal, + }, + { + activity: () => undefined, + status: (status) => { + statuses.push(status.type === 'notice' ? status.code : status.type) + controller.abort() + }, + }, + ) + + expect(statuses).toEqual(['connect_failed']) + expect(summary).toEqual({activities: 0, stoppedBy: 'signal'}) + }) + + test('places a call and bridges raw PCM in both directions', async () => { + const input = pcm(640) + const inputPath = join(directory, 'in.pcm') + const outputPath = join(directory, 'out.pcm') + await writeFile(inputPath, input) + const mediaFrames: Buffer[] = [] + let leaveFrame = false + server = await startCallsServer({ + onSocket: async (socket) => { + const auth = JSON.parse(String(await socket.next())) as Record< + string, + unknown + > + if (socket.path.startsWith('/voip/ws')) { + socket.sendJson({participant: 'server:default', type: 'ready'}) + await delay(100) + sendEvent(socket, 'call.accepted', 'call_9', { + answeredBy: 'server:default', + exclusive: false, + }) + return + } + expect(socket.path).toBe('/voip/calls/call_9/media') + expect(socket.protocol).toBe('pmfa.calls.v2') + expect(auth).toMatchObject({token: API_KEY, type: 'auth'}) + expect(String(auth.connectionId)).toMatch(/^[A-Za-z0-9_-]{8,64}$/) + socket.sendJson({ + callId: 'call_9', + sampleRate: 16000, + type: 'ready', + video: false, + }) + socket.sendBinary(Uint8Array.from([0x01, 0x01, 0x00, 0x02, 0x00])) + for (;;) { + const message = await socket.next() + if (typeof message !== 'string') { + mediaFrames.push(message) + continue + } + if ((JSON.parse(message) as {type?: string}).type === 'leave') { + leaveFrame = true + return + } + } + }, + rest: () => ({ + body: { + data: {callId: 'call_9', session: 'support', video: false}, + success: true, + }, + status: 201, + }), + }) + + const result = await apiKey(() => + runCommand( + `calls media --session support --place +15550100 --input ${inputPath} --output ${outputPath} --leave-on-eof --json`, + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toBe('') + const statuses = result.stderr + .trim() + .split('\n') + .map((line) => (JSON.parse(line) as {type: string}).type) + expect(statuses).toEqual([ + 'ready', + 'placed', + 'state', + 'state', + 'connected', + 'input_ended', + 'ended', + 'finished', + ]) + expect(JSON.parse(result.stderr.trim().split('\n').at(-1)!)).toMatchObject({ + bytesReceived: 4, + bytesSent: 1280, + callId: 'call_9', + reason: 'left', + }) + expect(await readFile(outputPath)).toEqual(Buffer.from([1, 0, 2, 0])) + expect( + Buffer.concat(mediaFrames.map((frame) => frame.subarray(1))), + ).toEqual(input) + expect(mediaFrames.every((frame) => frame[0] === 0x01)).toBe(true) + expect(leaveFrame).toBe(true) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/voip/calls', + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + exclusive: false, + session: 'support', + to: '+15550100', + video: false, + }) + }) + + test('answers only the requested call and exits on a claim conflict', async () => { + const outputPath = join(directory, 'out.pcm') + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({participant: 'server:agent-7', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_other', {from: '+15550199'}) + sendEvent(socket, 'call.received', 'call_1', {from: '+15550100'}) + }, + rest: () => ({ + body: { + data: null, + error: { + code: 'call_claimed', + message: 'The request conflicts with the resource state.', + type: 'conflict_error', + }, + }, + status: 409, + }), + }) + + const result = await apiKey(() => + runCommand( + `calls media call_1 --session support --participant agent-7 --exclusive --output ${outputPath} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(4) + const lines = result.stderr.trim().split('\n') + expect(JSON.parse(lines.at(-1)!)).toMatchObject({ + category: 'conflict', + code: 'call_claimed', + exitCode: 4, + }) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.url).toBe('/messaging/voip/calls/call_1/accept') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + exclusive: true, + participant: 'agent-7', + video: false, + }) + }) + + test('paces input audio in real time and leaves when interrupted', async () => { + const controller = new AbortController() + let received = 0 + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + if (socket.path.startsWith('/voip/ws')) { + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_3', {from: '+15550100'}) + return + } + socket.sendJson({sampleRate: 16000, type: 'ready', video: false}) + for (;;) { + const message = await socket.next() + if (typeof message !== 'string') received += message.byteLength - 1 + else return + } + }, + rest: () => ({ + body: { + data: { + answered: true, + answeredBy: 'server:default', + exclusive: false, + }, + success: true, + }, + }), + }) + + const summary = await bridgeCallMedia( + { + baseUrl: server.baseUrl, + credential: {type: 'apiKey', value: API_KEY}, + input: Readable.from([pcm(16_000)]), + output: () => undefined, + session: 'support', + signal: controller.signal, + }, + (status) => { + if (status.type === 'connected') { + setTimeout(() => controller.abort(), 200) + } + }, + ) + + expect(summary.reason).toBe('left') + // One second of audio must not be sent in 200 ms. + expect(summary.bytesSent).toBeGreaterThan(0) + expect(summary.bytesSent).toBeLessThan(16_000) + await delay(50) + expect(received).toBe(summary.bytesSent) + }) + + test('refuses a dependent flag without input before connecting', async () => { + server = await startCallsServer({}) + + const result = await apiKey(() => + runCommand( + `calls media --session support --leave-on-eof --output ${join(directory, 'out.pcm')}`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(server.sockets).toHaveLength(0) + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } + + async function clientToken(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: CLIENT_TOKEN, + }, + run, + ) + } +}) + +function sendEvent( + socket: FakeSocket, + event: string, + callId: string, + payload: Record, +): void { + socket.sendJson({ + callId, + event, + payload: {callId, ...payload}, + timestamp: TIMESTAMP, + type: 'event', + }) +} + +function pcm(samples: number): Buffer { + const bytes = Buffer.alloc(samples * 2) + for (let index = 0; index < samples; index += 1) { + bytes.writeInt16LE(index % 32_000, index * 2) + } + return bytes +} + +function withoutTime(value: Record): Record { + const {at, ...rest} = value + if (at !== undefined) expect(typeof at).toBe('string') + return rest +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts new file mode 100644 index 00000000..284b38fb --- /dev/null +++ b/test/commands/calls.test.ts @@ -0,0 +1,421 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type ResponseFixture, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const CLIENT_TOKEN = 'pmfa_ct_browser_token' + +describe('unified calls commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('places a call from a named session with a claim and participant', async () => { + server = await startHttpServer({ + body: { + data: {callId: 'call_1', session: 'support', video: true}, + success: true, + }, + headers: {'request-id': 'req_place'}, + status: 201, + }) + + const result = await apiKey(() => + runCommand( + 'calls place " +15550100 " --session support --video --exclusive --participant agent-7 --json', + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toEqual({ + data: {callId: 'call_1', session: 'support', video: true}, + metadata: {attempts: 1, requestId: 'req_place', status: 201}, + }) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/voip/calls', + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + exclusive: true, + participant: 'agent-7', + session: 'support', + to: '+15550100', + video: true, + }) + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${API_KEY}`) + }) + + test('places a call with a client token from its bound session', async () => { + server = await startHttpServer({ + body: { + data: {callId: 'call_2', session: 'support', video: false}, + success: true, + }, + status: 201, + }) + + const result = await clientToken(() => runCommand('calls place 15550100')) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Call\tcall_2') + expect(result.stdout).toContain('Session\tsupport') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + to: '15550100', + }) + expect(server.requests[0]?.headers.authorization).toBe( + `Bearer ${CLIENT_TOKEN}`, + ) + }) + + test('validates placement input before transport', async () => { + server = await startHttpServer({body: {success: true}}) + + const noSession = await apiKey(() => + runCommand('calls place +15550100 --json'), + ) + const participant = await clientToken(() => + runCommand('calls place +15550100 --participant agent-7 --json'), + ) + const target = await apiKey(() => + runCommand('calls place 0555 --session support --json'), + ) + const badParticipant = await apiKey(() => + runCommand( + 'calls place +15550100 --session support --participant "agent 7" --json', + ), + ) + + expect(exitCode(noSession.error)).toBe(2) + expect(noSession.stderr).toContain('requires --session') + expect(exitCode(participant.error)).toBe(2) + expect(participant.stderr).toContain( + '--participant requires a server API key', + ) + expect(exitCode(target.error)).toBe(2) + expect(target.stderr).toContain('E.164 phone number') + expect(exitCode(badParticipant.error)).toBe(2) + expect(badParticipant.stderr).toContain('Participant must be 1 to 128') + expect(server.requests).toHaveLength(0) + }) + + test('accepts a call and prints who answered and whether it is claimed', async () => { + server = await startHttpServer({ + body: { + data: {answered: true, answeredBy: 'server:agent-7', exclusive: true}, + success: true, + }, + }) + + const result = await apiKey(() => + runCommand( + 'calls accept call/1 --exclusive --no-video --participant agent-7', + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toBe( + 'Answered\tyes\nAnswered by\tserver:agent-7\nExclusive\tyes\n', + ) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/voip/calls/call%2F1/accept', + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + exclusive: true, + participant: 'agent-7', + video: false, + }) + }) + + test('joins a shared call with a client token', async () => { + server = await startHttpServer({ + body: { + data: {answered: true, answeredBy: 'server:default', exclusive: false}, + success: true, + }, + }) + + const result = await clientToken(() => + runCommand('calls accept call_1 --json'), + ) + + expect(result.error).toBeUndefined() + expect(jsonData(result.stdout)).toEqual({ + answered: true, + answeredBy: 'server:default', + exclusive: false, + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({}) + }) + + test('exits with a conflict when another participant claimed the call', async () => { + server = await startHttpServer( + apiError(409, 'call_claimed', 'The request conflicts.', 'req_claimed'), + ) + + const human = await apiKey(() => runCommand('calls accept call_1')) + const json = await apiKey(() => runCommand('calls accept call_1 --json')) + + expect(exitCode(human.error)).toBe(4) + expect(human.stderr).toContain('Another participant claimed this call') + expect(human.stderr).toContain('Request ID: req_claimed') + expect(exitCode(json.error)).toBe(4) + expect(JSON.parse(json.stderr)).toMatchObject({ + category: 'conflict', + code: 'call_claimed', + exitCode: 4, + requestId: 'req_claimed', + }) + expect(server.requests).toHaveLength(2) + }) + + test('reports an authentication failure with the credential redacted', async () => { + server = await startHttpServer( + apiError(401, 'invalid_credential', `Key ${API_KEY} is invalid.`), + ) + + const result = await apiKey(() => runCommand('calls accept call_1')) + + expect(exitCode(result.error)).toBe(3) + expect(result.stderr).toContain('[REDACTED]') + expect(result.stderr).not.toContain(API_KEY) + }) + + test('declines a ringing call on the unified Calls API', async () => { + server = await startHttpServer({body: {success: true}, status: 202}) + + const result = await apiKey(() => + runCommand('calls reject call_1 --participant agent-7'), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toBe('Declined call call_1.\n') + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({participant: 'agent-7'}), + method: 'POST', + url: '/messaging/voip/calls/call_1/reject', + }) + }) + + test('exits with a conflict when the call is no longer ringing', async () => { + server = await startHttpServer( + apiError(409, 'call_not_ringing', 'The request conflicts.'), + ) + + const result = await clientToken(() => + runCommand('calls reject call_1 --json'), + ) + + expect(exitCode(result.error)).toBe(4) + expect(JSON.parse(result.stderr)).toMatchObject({ + category: 'conflict', + code: 'call_not_ringing', + }) + expect(result.stderr).toContain('The call is not ringing') + expect(server.requests[0]?.body).toBe('') + }) + + test('leaves one connection and validates its ID first', async () => { + server = await startHttpServer({body: {success: true}, status: 202}) + + const invalid = await apiKey(() => + runCommand('calls leave call_1 --connection-id short --json'), + ) + const spaced = await apiKey(() => + runCommand('calls leave call_1 --connection-id "conn 0123456" --json'), + ) + const left = await apiKey(() => + runCommand( + 'calls leave call_1 --connection-id conn_0123456789 --participant agent-7', + ), + ) + + expect(exitCode(invalid.error)).toBe(2) + expect(invalid.stderr).toContain('Connection ID must be 8 to 64') + expect(exitCode(spaced.error)).toBe(2) + expect(left.error).toBeUndefined() + expect(left.stdout).toBe( + 'Closed connection conn_0123456789 on call call_1.\n', + ) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/voip/calls/call_1/leave', + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + connectionId: 'conn_0123456789', + participant: 'agent-7', + }) + }) + + test('ends a call for everyone with a client token', async () => { + server = await startHttpServer({body: {success: true}}) + + const result = await clientToken(() => + runCommand('calls end call_1 --json'), + ) + + expect(result.error).toBeUndefined() + expect(jsonData(result.stdout)).toEqual({success: true}) + expect(server.requests[0]).toMatchObject({ + method: 'DELETE', + url: '/messaging/voip/calls/call_1', + }) + }) + + test('invites a participant into a call', async () => { + server = await startHttpServer({ + body: { + data: { + audioMuted: false, + id: 'participant_1', + phoneNumber: '+15550101', + state: 'invited', + video: false, + }, + success: true, + }, + status: 201, + }) + + const result = await apiKey(() => + runCommand('calls invite call_1 +15550101'), + ) + const invalid = await apiKey(() => + runCommand('calls invite call_1 alice --json'), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toBe( + 'Participant\tparticipant_1\nPhone\t+15550101\nState\tinvited\n', + ) + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({to: '+15550101'}), + method: 'POST', + url: '/messaging/voip/calls/call_1/participants', + }) + expect(exitCode(invalid.error)).toBe(2) + expect(server.requests).toHaveLength(1) + }) + + test('reads and replaces session call settings with a server API key', async () => { + server = await startHttpServer({ + body: { + data: {includeSelfAudio: true, updatedAt: '2026-09-16T10:00:00.000Z'}, + success: true, + }, + }) + + const viewed = await apiKey(() => + runCommand('calls settings get --session support/main'), + ) + const updated = await apiKey(() => + runCommand( + 'calls settings set --session support/main --include-self-audio=true --json', + ), + ) + + expect(viewed.error).toBeUndefined() + expect(viewed.stdout).toBe( + 'Session\tsupport/main\nInclude self audio\tyes\nUpdated\t2026-09-16T10:00:00.000Z\n', + ) + expect(updated.error).toBeUndefined() + expect(jsonData(updated.stdout)).toEqual({ + includeSelfAudio: true, + updatedAt: '2026-09-16T10:00:00.000Z', + }) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/sessions/support%2Fmain/call-settings', + 'PUT /platform/sessions/support%2Fmain/call-settings', + ]) + expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + includeSelfAudio: true, + }) + }) + + test('rejects client tokens and invalid values for call settings', async () => { + server = await startHttpServer({body: {success: true}}) + + const token = await clientToken(() => + runCommand('calls settings get --session support --json'), + ) + const value = await apiKey(() => + runCommand( + 'calls settings set --session support --include-self-audio=yes --json', + ), + ) + + expect(exitCode(token.error)).toBe(2) + expect(token.stderr).toContain( + 'Platform API requests require POLYMORFA_API_KEY', + ) + expect(exitCode(value.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('distinguishes the unified and session-route reject commands in help', async () => { + const unified = await runCommand('calls reject --help') + const legacy = await runCommand('call reject --help') + + expect(unified.stdout).toContain('use call reject') + expect(legacy.stdout).toContain('use calls reject') + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } + + async function clientToken(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: CLIENT_TOKEN, + }, + run, + ) + } +}) + +function apiError( + status: number, + code: string, + message: string, + requestId?: string, +): ResponseFixture { + return { + body: { + data: null, + error: {code, message, param: null, type: 'conflict_error'}, + }, + ...(requestId === undefined ? {} : {headers: {'request-id': requestId}}), + status, + } +} + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} + +function jsonData(stdout: string): unknown { + return (JSON.parse(stdout) as {data: unknown}).data +} diff --git a/test/commands/customers.test.ts b/test/commands/customers.test.ts index 3284742d..2017debd 100644 --- a/test/commands/customers.test.ts +++ b/test/commands/customers.test.ts @@ -94,7 +94,7 @@ describe('Customer commands', () => { const created = await environment(() => runCommand( - 'customer create --project project/1 --name Ada --phone +15551234567 --external-id crm/1 --idempotency-key create_1 --json', + 'customer create --project project/1 --name Ada --external-id crm/1 --idempotency-key create_1 --json', ), ) const viewed = await environment(() => @@ -102,7 +102,7 @@ describe('Customer commands', () => { ) const updated = await environment(() => runCommand( - 'customer update customer/1 --project project/1 --clear-name --phone +15557654321 --json', + 'customer update customer/1 --project project/1 --clear-name --json', ), ) @@ -117,12 +117,10 @@ describe('Customer commands', () => { expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ externalCustomerId: 'crm/1', name: 'Ada', - phone: '+15551234567', projectId: 'project/1', }) expect(JSON.parse(server.requests[2]?.body ?? '')).toEqual({ name: null, - phone: '+15557654321', projectId: 'project/1', }) }) @@ -195,7 +193,7 @@ describe('Customer commands', () => { const created = await environment(() => runCommand( - 'customer pairing-link create customer/1 --project project/1 --expected-phone +15551234567 --method qr --method phone --expires-in 3600 --locale en --theme system --idempotency-key link_1 --json', + 'customer pairing-link create customer/1 --project project/1 --expected-phone +15551234567 --method qr --method phone --expires-in 3600 --idempotency-key link_1 --json', ), ) const revoked = await environment(() => @@ -220,10 +218,8 @@ describe('Customer commands', () => { expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ expectedPhone: '+15551234567', expiresInSeconds: 3600, - locale: 'en', methods: ['qr', 'phone'], projectId: 'project/1', - theme: 'system', }) expect(JSON.parse(server.requests[2]?.body ?? '')).toEqual({ confirm: true, @@ -275,7 +271,6 @@ function customer() { isDefault: false, name: 'Ada', orgId: 'org_1', - phone: '+15551234567', projectId: 'project/1', status: 'active', updatedAt: 1_725_000_000_000, @@ -283,16 +278,13 @@ function customer() { } function customerSummary() { - const {phone: _phone, ...value} = customer() - void _phone return { - ...value, + ...customer(), activePairingLinkState: 'active', connectedNumberCount: 1, lastActivityAt: 1_725_000_000_000, needsAttention: false, numberCount: 1, - phoneMasked: '+1•••4567', } } diff --git a/test/commands/platform-management.test.ts b/test/commands/platform-management.test.ts index c752a452..06b58260 100644 --- a/test/commands/platform-management.test.ts +++ b/test/commands/platform-management.test.ts @@ -381,7 +381,7 @@ describe('Platform management commands', () => { test('updates a widget patch with separate project scope and idempotency', async () => { const input = await jsonInput('widget.json', { - allowedRedirectUris: ['https://app.example.com/connected'], + successCallbackUrl: 'https://app.example.com/connected', businessName: 'Example', enabled: true, methods: ['qr'], @@ -397,7 +397,7 @@ describe('Platform management commands', () => { expect(result.error).toBeUndefined() expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ - allowedRedirectUris: ['https://app.example.com/connected'], + successCallbackUrl: 'https://app.example.com/connected', businessName: 'Example', enabled: true, methods: ['qr'], @@ -532,7 +532,9 @@ function widgetSettings() { return { accent: null, allowedOrigins: ['https://app.example.com'], - allowedRedirectUris: [], + allowPhoneChange: false, + failureCallbackUrl: null, + successCallbackUrl: null, businessName: 'Example', colors: null, createdAt: 1_723_000_000_000, diff --git a/test/errors/present.test.ts b/test/errors/present.test.ts index 8e557b71..5bef6838 100644 --- a/test/errors/present.test.ts +++ b/test/errors/present.test.ts @@ -2,6 +2,7 @@ import { PolymorfaAuthenticationError, PolymorfaCancelledError, PolymorfaConfigurationError, + PolymorfaConflictError, } from '@polymorfa/sdk' import {describe, expect, test} from 'vitest' @@ -51,4 +52,22 @@ describe('presentError', () => { requestId: 'req_123', }) }) + + test('keeps the stable API code of a conflict', () => { + const error = new PolymorfaConflictError( + 'Another participant claimed it.', + { + code: 'call_claimed', + requestId: 'req_409', + }, + ) + + expect(presentError(error)).toEqual({ + category: 'conflict', + code: 'call_claimed', + exitCode: 4, + message: 'Another participant claimed it.', + requestId: 'req_409', + }) + }) }) diff --git a/test/helpers/calls-server.ts b/test/helpers/calls-server.ts new file mode 100644 index 00000000..b0f62be2 --- /dev/null +++ b/test/helpers/calls-server.ts @@ -0,0 +1,238 @@ +import {createHash} from 'node:crypto' +import {createServer, type IncomingMessage} from 'node:http' +import type {Duplex} from 'node:stream' + +import type {CapturedRequest, ResponseFixture} from './http-server.js' + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + +export type SocketMessage = string | Buffer + +/** One accepted WebSocket connection, seen from the fake API. */ +export interface FakeSocket { + readonly closed: Promise<{readonly code?: number}> + readonly messages: SocketMessage[] + readonly path: string + readonly protocol?: string + close(code: number, reason?: string): void + next(): Promise + sendBinary(bytes: Uint8Array): void + sendJson(value: unknown): void +} + +export interface CallsServer { + readonly baseUrl: string + readonly requests: CapturedRequest[] + readonly sockets: FakeSocket[] + close(): Promise +} + +export interface CallsServerOptions { + readonly onSocket?: (socket: FakeSocket) => void | Promise + readonly rest?: (request: CapturedRequest) => ResponseFixture +} + +/** + * A local HTTP and minimal RFC 6455 server, so tests exercise the real Calls + * client over Node's WebSocket without a network. + */ +export async function startCallsServer( + options: CallsServerOptions, +): Promise { + const requests: CapturedRequest[] = [] + const sockets: FakeSocket[] = [] + const raw = new Set() + const server = createServer((request, reply) => { + void (async () => { + const chunks: Buffer[] = [] + for await (const chunk of request as AsyncIterable) { + chunks.push(Buffer.from(chunk)) + } + const captured: CapturedRequest = { + body: Buffer.concat(chunks).toString('utf8'), + headers: request.headers, + method: request.method ?? '', + url: request.url ?? '', + } + requests.push(captured) + const response = options.rest?.(captured) ?? { + body: {error: 'No REST fixture.'}, + status: 500, + } + reply.writeHead(response.status ?? 200, { + 'content-type': 'application/json', + ...response.headers, + }) + reply.end(JSON.stringify(response.body)) + })() + }) + + server.on('upgrade', (request: IncomingMessage, socket: Duplex) => { + raw.add(socket) + socket.on('close', () => raw.delete(socket)) + const key = request.headers['sec-websocket-key'] + const offered = request.headers['sec-websocket-protocol'] + const protocol = + typeof offered === 'string' ? offered.split(',')[0]?.trim() : undefined + const accept = createHash('sha1') + .update(`${String(key)}${GUID}`) + .digest('base64') + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + ...(protocol === undefined + ? [] + : [`Sec-WebSocket-Protocol: ${protocol}`]), + '', + '', + ].join('\r\n'), + ) + const fake = acceptSocket(socket, request.url ?? '', protocol) + sockets.push(fake) + void Promise.resolve(options.onSocket?.(fake)).catch(() => { + fake.close(1011, 'test failure') + }) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('Calls test server did not bind to a TCP port.') + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + sockets, + close: () => + new Promise((resolve, reject) => { + for (const socket of raw) socket.destroy() + server.close((error) => + error === undefined ? resolve() : reject(error), + ) + }), + } +} + +function acceptSocket( + socket: Duplex, + path: string, + protocol: string | undefined, +): FakeSocket { + const messages: SocketMessage[] = [] + const waiters: Array<(message: SocketMessage) => void> = [] + let buffer = Buffer.alloc(0) + let closedResolve: (value: {code?: number}) => void = () => undefined + const closed = new Promise<{code?: number}>((resolve) => { + closedResolve = resolve + }) + let isClosed = false + + const write = (opcode: number, payload: Buffer): void => { + if (isClosed || socket.destroyed) return + const length = payload.byteLength + let header: Buffer + if (length < 126) { + header = Buffer.from([0x80 | opcode, length]) + } else if (length < 65_536) { + header = Buffer.alloc(4) + header[0] = 0x80 | opcode + header[1] = 126 + header.writeUInt16BE(length, 2) + } else { + header = Buffer.alloc(10) + header[0] = 0x80 | opcode + header[1] = 127 + header.writeBigUInt64BE(BigInt(length), 2) + } + socket.write(Buffer.concat([header, payload])) + } + + let read = 0 + const deliver = (message: SocketMessage): void => { + messages.push(message) + const waiter = waiters.shift() + if (waiter !== undefined) { + read += 1 + waiter(message) + } + } + + socket.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + for (;;) { + if (buffer.byteLength < 2) return + const opcode = buffer[0]! & 0x0f + let length = buffer[1]! & 0x7f + let offset = 2 + if (length === 126) { + if (buffer.byteLength < 4) return + length = buffer.readUInt16BE(2) + offset = 4 + } else if (length === 127) { + if (buffer.byteLength < 10) return + length = Number(buffer.readBigUInt64BE(2)) + offset = 10 + } + const masked = (buffer[1]! & 0x80) !== 0 + const total = offset + (masked ? 4 : 0) + length + if (buffer.byteLength < total) return + const mask = masked ? buffer.subarray(offset, offset + 4) : undefined + const payload = Buffer.from( + buffer.subarray(offset + (masked ? 4 : 0), total), + ) + if (mask !== undefined) { + for (let index = 0; index < payload.byteLength; index += 1) { + payload[index] = payload[index]! ^ mask[index % 4]! + } + } + buffer = buffer.subarray(total) + if (opcode === 0x1) deliver(payload.toString('utf8')) + else if (opcode === 0x2) deliver(payload) + else if (opcode === 0x9) write(0xa, payload) + else if (opcode === 0x8) { + const code = + payload.byteLength >= 2 ? payload.readUInt16BE(0) : undefined + write(0x8, payload.subarray(0, 2)) + isClosed = true + closedResolve(code === undefined ? {} : {code}) + socket.end() + } + } + }) + socket.on('close', () => { + isClosed = true + closedResolve({}) + }) + + return { + closed, + messages, + path, + ...(protocol === undefined ? {} : {protocol}), + close: (code, reason = '') => { + const payload = Buffer.alloc(2 + Buffer.byteLength(reason)) + payload.writeUInt16BE(code, 0) + payload.write(reason, 2) + write(0x8, payload) + isClosed = true + closedResolve({code}) + setTimeout(() => socket.end(), 20) + }, + next: () => { + if (read < messages.length) { + read += 1 + return Promise.resolve(messages[read - 1]!) + } + return new Promise((resolve) => waiters.push(resolve)) + }, + sendBinary: (bytes) => write(0x2, Buffer.from(bytes)), + sendJson: (value) => write(0x1, Buffer.from(JSON.stringify(value))), + } +} From 88ef849616cbd740e04bc47cfaa87ed9c18f9796 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 00:23:59 +0300 Subject: [PATCH 02/34] chore(deps): pin the SDK commit that reports call capabilities --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd39e00c..b532bfd4 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#a65291cf6de0574b655affdcab5e6fe44c517b53" + "@polymorfa/sdk": "github:polymorfa/sdks#de600b49893a3a8ca398217eb7e76490c47a50cf" }, "devDependencies": { "@eslint/js": "^9.39.5", From d993756c27a8cfa862c571492045ec49b0dab7dc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 01:33:20 +0300 Subject: [PATCH 03/34] feat(calls): manage SIP trunks and route incoming calls Add the sip-trunk commands and --inbound-route, --sip-trunk and --sip-claim on calls settings set, which now keeps settings you do not pass. Pin the SDK commit that adds Client.sipTrunks (54d2518, unpushed; lockfile not regenerated). --- README.md | 33 ++- docs/command-research/calls.md | 30 ++- package.json | 5 +- src/commands/calls/settings/set.ts | 37 ++- src/commands/sip-trunk/create.ts | 37 +++ src/commands/sip-trunk/delete.ts | 34 +++ src/commands/sip-trunk/list.ts | 26 ++ src/commands/sip-trunk/rotate-credentials.ts | 34 +++ src/commands/sip-trunk/update.ts | 59 +++++ src/commands/sip-trunk/view.ts | 24 ++ src/services/calls.ts | 58 ++++- src/services/sip-trunks.ts | 245 +++++++++++++++++++ test/commands/calls.test.ts | 100 +++++++- test/commands/sip-trunks.test.ts | 232 ++++++++++++++++++ 14 files changed, 929 insertions(+), 25 deletions(-) create mode 100644 src/commands/sip-trunk/create.ts create mode 100644 src/commands/sip-trunk/delete.ts create mode 100644 src/commands/sip-trunk/list.ts create mode 100644 src/commands/sip-trunk/rotate-credentials.ts create mode 100644 src/commands/sip-trunk/update.ts create mode 100644 src/commands/sip-trunk/view.ts create mode 100644 src/services/sip-trunks.ts create mode 100644 test/commands/sip-trunks.test.ts diff --git a/README.md b/README.md index c91dd2c4..2fd13522 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,8 @@ polymorfa calls media --session support --place +15550100 \ polymorfa calls settings get --session support polymorfa calls settings set --session support --include-self-audio=true +polymorfa calls settings set --session support \ + --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 ``` Every inbound call rings until a participant answers or declines it. The CLI @@ -445,8 +447,35 @@ never answers or declines on its own. end, invite, watch, and carry audio when the session's client rules allow it. The API decides; the CLI reports its errors unchanged. - **Settings.** `calls settings` uses the Platform API and requires a server - API key. `includeSelfAudio` controls whether the merged audio a connection - receives includes its own audio. It is off by default. + API key. `--include-self-audio` controls whether the merged audio a + connection receives includes its own audio. It is off by default. + `--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a + SIP trunk; `--sip-claim` controls whether the trunk's answer claims the + call. `calls settings set` reads the current settings and sends them back + with your changes, so a flag you omit keeps its value. + +### SIP trunks + +SIP trunks connect a PBX to WhatsApp calls. They are a beta: changes fail with +exit code 3 until your team is enrolled. Commands require a server API key. + +```shell +polymorfa sip-trunk list --project project_123 +polymorfa sip-trunk view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 +polymorfa sip-trunk create --project project_123 --input trunk.json +polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ + --disable --expected-revision 3 +polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --input changes.json +polymorfa sip-trunk rotate-credentials 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes +polymorfa sip-trunk delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes +``` + +`create` and `update` read the request body from a file or stdin (`-`), so +passwords never appear in the command line. `create` refuses a body that names +`projectId`; use `--project`. A trunk that accepts calls from your PBX, and +`rotate-credentials`, print the password once. `update --expected-revision` +exits with code 4 when the trunk changed; `delete` exits with code 4 while a +session routes calls to the trunk. `calls watch` streams inbound calls and their progress: `incoming`, `claim`, `state`, `participant_joined`, `participant_state`, `participant_left`, and diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 96b4914a..be0c3c8f 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -40,18 +40,24 @@ calls ## Operation mapping -| Command | Operation | SDK surface | -| -------------------- | ------------------------------------------------ | ----------------------------------- | -| `calls place` | `POST /messaging/voip/calls` | `voip.place` | -| `calls accept` | `POST /messaging/voip/calls/{id}/accept` | `voip.accept` | -| `calls reject` | `POST /messaging/voip/calls/{id}/reject` | `voip.reject` | -| `calls leave` | `POST /messaging/voip/calls/{id}/leave` | `voip.leave` | -| `calls end` | `DELETE /messaging/voip/calls/{id}` | `voip.end` | -| `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` | -| `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` | -| `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` | -| `calls watch` | Lifecycle socket | `CallsClient` events | -| `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` | +| Command | Operation | SDK surface | +| ------------------------------ | ------------------------------------------------ | ----------------------------------- | +| `calls place` | `POST /messaging/voip/calls` | `voip.place` | +| `calls accept` | `POST /messaging/voip/calls/{id}/accept` | `voip.accept` | +| `calls reject` | `POST /messaging/voip/calls/{id}/reject` | `voip.reject` | +| `calls leave` | `POST /messaging/voip/calls/{id}/leave` | `voip.leave` | +| `calls end` | `DELETE /messaging/voip/calls/{id}` | `voip.end` | +| `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` | +| `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` | +| `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` | +| `sip-trunk list` | `GET /platform/sip-trunks` | `sipTrunks.list` | +| `sip-trunk view` | `GET /platform/sip-trunks/{id}` | `sipTrunks.retrieve` | +| `sip-trunk create` | `POST /platform/sip-trunks` | `sipTrunks.create` | +| `sip-trunk update` | `PATCH /platform/sip-trunks/{id}` | `sipTrunks.update` | +| `sip-trunk delete` | `DELETE /platform/sip-trunks/{id}` | `sipTrunks.delete` | +| `sip-trunk rotate-credentials` | `POST /platform/sip-trunks/{id}/credentials` | `sipTrunks.rotateCredentials` | +| `calls watch` | Lifecycle socket | `CallsClient` events | +| `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` | The CLI uses only the neutral public calling surface. It does not expose offers, candidates, session descriptions, or media framing, and it imports no diff --git a/package.json b/package.json index b532bfd4..6681cc03 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#de600b49893a3a8ca398217eb7e76490c47a50cf" + "@polymorfa/sdk": "github:polymorfa/sdks#54d2518eb1e935c2d0f530d8c0d6ec1fb0f0f001" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -194,6 +194,9 @@ "session": { "description": "Manage Platform sessions." }, + "sip-trunk": { + "description": "Connect a PBX to WhatsApp calls with SIP trunks (beta)." + }, "session-ban": { "description": "Inspect recorded organization session bans." }, diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts index b26d40e9..429de8fd 100644 --- a/src/commands/calls/settings/set.ts +++ b/src/commands/calls/settings/set.ts @@ -9,10 +9,12 @@ import { export default class CallsSettingsSet extends BaseCommand { static override description = - 'Replace a session’s call settings through the Platform API. includeSelfAudio controls whether the merged audio a connection receives includes its own audio (off by default). WhatsApp never hears its own audio. Requires a server API key.' + 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' static override examples = [ '<%= config.bin %> calls settings set --session support --include-self-audio=true', + '<%= config.bin %> calls settings set --session support --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', + '<%= config.bin %> calls settings set --session support --inbound-route clients', ] static override flags = { @@ -20,19 +22,46 @@ export default class CallsSettingsSet extends BaseCommand { description: 'Include each connection’s own audio in the merged audio it receives.', options: ['true', 'false'], - required: true, + }), + 'inbound-route': Flags.string({ + description: + 'Where incoming calls ring: clients, or sip_trunk to also send them to a SIP trunk.', + options: ['clients', 'sip_trunk'], }), session: Flags.string({description: 'Session ID.', required: true}), + 'sip-claim': Flags.string({ + description: + 'Whether an answer from the SIP trunk claims the call and stops other clients ringing.', + options: ['true', 'false'], + }), + 'sip-trunk': Flags.string({ + description: 'SIP trunk ID that receives incoming calls.', + }), } - static override summary = 'Replace session call settings.' + static override summary = 'Change session call settings.' async run(): Promise { const {flags} = await this.parse(CallsSettingsSet) const result = await updateCallSettings( createCallSettingsClient(await this.platformEnvironment(flags)), flags.session, - flags['include-self-audio'] === 'true', + { + ...(flags['include-self-audio'] === undefined + ? {} + : {includeSelfAudio: flags['include-self-audio'] === 'true'}), + ...(flags['inbound-route'] === undefined + ? {} + : { + inboundRoute: flags['inbound-route'] as 'clients' | 'sip_trunk', + }), + ...(flags['sip-claim'] === undefined + ? {} + : {sipClaim: flags['sip-claim'] === 'true'}), + ...(flags['sip-trunk'] === undefined + ? {} + : {sipTrunkId: flags['sip-trunk']}), + }, ) this.writeValue( result, diff --git a/src/commands/sip-trunk/create.ts b/src/commands/sip-trunk/create.ts new file mode 100644 index 00000000..91c72d46 --- /dev/null +++ b/src/commands/sip-trunk/create.ts @@ -0,0 +1,37 @@ +import {Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import { + createSipTrunk, + renderSipTrunkCreated, + requireSipTrunkProject, +} from '../../services/sip-trunks.js' + +export default class SipTrunkCreate extends BaseCommand { + static override description = + 'Create a SIP trunk from one JSON object with name, direction, and the outbound and inbound sections the direction needs. Keep passwords in the input file or stdin, not in arguments. A trunk that accepts calls from your PBX returns its password once. SIP trunks are a beta. Requires a server API key.' + + static override examples = [ + '<%= config.bin %> sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json', + ] + + static override flags = { + input: Flags.string({ + description: 'Read the SIP trunk JSON object from a file or stdin (-).', + required: true, + }), + } + + static override summary = 'Create a SIP trunk.' + + async run(): Promise { + const {flags} = await this.parse(SipTrunkCreate) + const result = await createSipTrunk( + await this.platformClient(flags), + requireSipTrunkProject(flags.project), + await readJsonInput(flags.input), + ) + this.writeValue(result, renderSipTrunkCreated(result.data), flags) + } +} diff --git a/src/commands/sip-trunk/delete.ts b/src/commands/sip-trunk/delete.ts new file mode 100644 index 00000000..523225ad --- /dev/null +++ b/src/commands/sip-trunk/delete.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + deleteSipTrunk, + renderSipTrunkDeleted, +} from '../../services/sip-trunks.js' + +export default class SipTrunkDelete extends BaseCommand { + static override args = { + trunk: Args.string({description: 'SIP trunk ID.', required: true}), + } + + static override description = + 'Delete a SIP trunk and its credentials. Fails with sip_trunk_in_use while a session routes incoming calls to it. Requires a server API key.' + + static override flags = { + yes: Flags.boolean({ + description: 'Confirm permanent deletion of the trunk.', + required: true, + }), + } + + static override summary = 'Delete a SIP trunk.' + + async run(): Promise { + const {args, flags} = await this.parse(SipTrunkDelete) + const result = await deleteSipTrunk( + await this.platformClient(flags), + args.trunk, + ) + this.writeValue(result, renderSipTrunkDeleted(result.data), flags) + } +} diff --git a/src/commands/sip-trunk/list.ts b/src/commands/sip-trunk/list.ts new file mode 100644 index 00000000..f231e4d0 --- /dev/null +++ b/src/commands/sip-trunk/list.ts @@ -0,0 +1,26 @@ +import {BaseCommand} from '../../base-command.js' +import { + listSipTrunks, + renderSipTrunks, + requireSipTrunkProject, +} from '../../services/sip-trunks.js' + +export default class SipTrunkList extends BaseCommand { + static override description = + 'List the SIP trunks of a project. Passwords are never returned. Requires a server API key.' + + static override examples = [ + '<%= config.bin %> sip-trunk list --project 018f0000-0000-7000-8000-000000000002', + ] + + static override summary = 'List SIP trunks.' + + async run(): Promise { + const {flags} = await this.parse(SipTrunkList) + const result = await listSipTrunks( + await this.platformClient(flags), + requireSipTrunkProject(flags.project), + ) + this.writeValue(result, renderSipTrunks(result.data), flags) + } +} diff --git a/src/commands/sip-trunk/rotate-credentials.ts b/src/commands/sip-trunk/rotate-credentials.ts new file mode 100644 index 00000000..40e40e14 --- /dev/null +++ b/src/commands/sip-trunk/rotate-credentials.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + renderSipTrunkCredentials, + rotateSipTrunkCredentials, +} from '../../services/sip-trunks.js' + +export default class SipTrunkRotateCredentials extends BaseCommand { + static override args = { + trunk: Args.string({description: 'SIP trunk ID.', required: true}), + } + + static override description = + 'Replace the password your PBX uses to place calls through the trunk and print it once. The previous password stops working within about a minute. SIP trunks are a beta. Requires a server API key.' + + static override flags = { + yes: Flags.boolean({ + description: 'Confirm that the current password will stop working.', + required: true, + }), + } + + static override summary = 'Rotate SIP trunk credentials.' + + async run(): Promise { + const {args, flags} = await this.parse(SipTrunkRotateCredentials) + const result = await rotateSipTrunkCredentials( + await this.platformClient(flags), + args.trunk, + ) + this.writeValue(result, renderSipTrunkCredentials(result.data), flags) + } +} diff --git a/src/commands/sip-trunk/update.ts b/src/commands/sip-trunk/update.ts new file mode 100644 index 00000000..b5a9fadc --- /dev/null +++ b/src/commands/sip-trunk/update.ts @@ -0,0 +1,59 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import {renderSipTrunk, updateSipTrunk} from '../../services/sip-trunks.js' + +export default class SipTrunkUpdate extends BaseCommand { + static override args = { + trunk: Args.string({description: 'SIP trunk ID.', required: true}), + } + + static override description = + 'Change a SIP trunk. Fields you omit keep their values; omit authPassword to keep the stored password. Changes reach new calls within about a minute. SIP trunks are a beta. Requires a server API key.' + + static override examples = [ + '<%= config.bin %> sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --disable --expected-revision 3', + '<%= config.bin %> sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --input changes.json', + ] + + static override flags = { + disable: Flags.boolean({ + description: 'Stop new calls on the trunk.', + exclusive: ['enable'], + }), + enable: Flags.boolean({ + description: 'Allow new calls on the trunk.', + exclusive: ['disable'], + }), + 'expected-revision': Flags.integer({ + description: + 'Refuse the change if the trunk no longer has this revision.', + min: 1, + }), + input: Flags.string({ + description: 'Read the JSON changes from a file or stdin (-).', + }), + } + + static override summary = 'Change a SIP trunk.' + + async run(): Promise { + const {args, flags} = await this.parse(SipTrunkUpdate) + const input = + flags.input === undefined ? undefined : await readJsonInput(flags.input) + const result = await updateSipTrunk( + await this.platformClient(flags), + args.trunk, + input, + { + ...(flags.enable === true ? {enabled: true} : {}), + ...(flags.disable === true ? {enabled: false} : {}), + ...(flags['expected-revision'] === undefined + ? {} + : {expectedRevision: flags['expected-revision']}), + }, + ) + this.writeValue(result, renderSipTrunk(result.data), flags) + } +} diff --git a/src/commands/sip-trunk/view.ts b/src/commands/sip-trunk/view.ts new file mode 100644 index 00000000..f281ad8e --- /dev/null +++ b/src/commands/sip-trunk/view.ts @@ -0,0 +1,24 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {renderSipTrunk, viewSipTrunk} from '../../services/sip-trunks.js' + +export default class SipTrunkView extends BaseCommand { + static override args = { + trunk: Args.string({description: 'SIP trunk ID.', required: true}), + } + + static override description = + 'Show one SIP trunk, including its revision for guarded updates. Requires a server API key.' + + static override summary = 'Show a SIP trunk.' + + async run(): Promise { + const {args, flags} = await this.parse(SipTrunkView) + const result = await viewSipTrunk( + await this.platformClient(flags), + args.trunk, + ) + this.writeValue(result, renderSipTrunk(result.data), flags) + } +} diff --git a/src/services/calls.ts b/src/services/calls.ts index a13a5dd8..b2393361 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -23,6 +23,7 @@ import { PolymorfaServerError, PolymorfaValidationError, type MessagingClient, + type CallInboundRoute, type MessagingCredential, type ResponseMetadata, type SessionCallSettings, @@ -250,15 +251,55 @@ export async function viewCallSettings( return result(response.data.data, response.metadata) } +export interface CallSettingsChanges { + readonly includeSelfAudio?: boolean + readonly inboundRoute?: CallInboundRoute + readonly sipClaim?: boolean + readonly sipTrunkId?: string +} + +/** + * Apply the given changes to the session's current settings. The API replaces + * every field on update, so unchanged fields are read first and sent back. + */ export async function updateCallSettings( client: MessagingClient, session: string, - includeSelfAudio: boolean, + changes: CallSettingsChanges, ): Promise> { - const response = await client.voip.updateCallSettings( - validateSession(session), - {includeSelfAudio}, - ) + const name = validateSession(session) + if (Object.values(changes).every((value) => value === undefined)) { + throw new PolymorfaConfigurationError( + 'Provide at least one setting to change.', + 'include-self-audio', + ) + } + if (changes.inboundRoute === 'clients' && changes.sipTrunkId !== undefined) { + throw new PolymorfaConfigurationError( + '--sip-trunk requires --inbound-route sip_trunk.', + 'sip-trunk', + ) + } + const current = (await client.voip.retrieveCallSettings(name)).data.data + const inboundRoute = + changes.inboundRoute ?? + (changes.sipTrunkId === undefined ? current.inboundRoute : 'sip_trunk') + const sipTrunkId = + inboundRoute === 'sip_trunk' + ? (changes.sipTrunkId ?? current.sipTrunkId) + : null + if (inboundRoute === 'sip_trunk' && sipTrunkId === null) { + throw new PolymorfaConfigurationError( + 'Provide --sip-trunk with the trunk that receives incoming calls.', + 'sip-trunk', + ) + } + const response = await client.voip.updateCallSettings(name, { + includeSelfAudio: changes.includeSelfAudio ?? current.includeSelfAudio, + inboundRoute, + sipClaim: changes.sipClaim ?? current.sipClaim, + ...(sipTrunkId === null ? {} : {sipTrunkId}), + }) return result(response.data.data, response.metadata) } @@ -347,6 +388,13 @@ export function renderCallSettings( return [ `Session\t${session}`, `Include self audio\t${yesNo(settings.includeSelfAudio)}`, + `Incoming calls\t${settings.inboundRoute === 'sip_trunk' ? 'clients and SIP trunk' : 'clients'}`, + ...(settings.inboundRoute === 'sip_trunk' + ? [ + `SIP trunk\t${settings.sipTrunkId ?? '-'}`, + `SIP answer claims call\t${yesNo(settings.sipClaim)}`, + ] + : []), `Updated\t${settings.updatedAt ?? 'never (defaults)'}`, ].join('\n') } diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts new file mode 100644 index 00000000..df752da7 --- /dev/null +++ b/src/services/sip-trunks.ts @@ -0,0 +1,245 @@ +import { + PolymorfaConfigurationError, + PolymorfaValidationError, + type Client as PlatformClient, + type CreateSipTrunkInput, + type ResponseMetadata, + type SipTrunk, + type SipTrunkCreated, + type SipTrunkCredentials, + type SipTrunkDeleted, +} from '@polymorfa/sdk' + +export interface SipTrunkResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export function requireSipTrunkProject(projectId?: string): string { + if (projectId === undefined || projectId.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Provide --project with the project ID that owns the SIP trunks.', + 'project', + ) + } + return projectId.trim() +} + +export async function listSipTrunks( + client: PlatformClient, + projectId: string, +): Promise> { + const response = await client.sipTrunks.list(projectId) + return result(response.data, response.metadata) +} + +export async function viewSipTrunk( + client: PlatformClient, + trunkId: string, +): Promise> { + const response = await client.sipTrunks.retrieve(validateTrunkId(trunkId)) + return result(response.data, response.metadata) +} + +export async function createSipTrunk( + client: PlatformClient, + projectId: string, + input: unknown, +): Promise> { + const body = objectInput(input, 'SIP trunk') + if ('projectId' in body) { + throw new PolymorfaValidationError( + 'SIP trunk input must not contain projectId; use --project.', + {code: 'invalid_sip_trunk_input'}, + ) + } + if (typeof body.name !== 'string' || typeof body.direction !== 'string') { + throw new PolymorfaValidationError( + 'SIP trunk input must contain name and direction.', + {code: 'invalid_sip_trunk_input'}, + ) + } + const response = await client.sipTrunks.create( + projectId, + body as unknown as CreateSipTrunkInput, + ) + return result(response.data, response.metadata) +} + +export interface SipTrunkUpdateFlags { + readonly enabled?: boolean + readonly expectedRevision?: number +} + +export async function updateSipTrunk( + client: PlatformClient, + trunkId: string, + input: unknown, + flags: SipTrunkUpdateFlags, +): Promise> { + const body = input === undefined ? {} : objectInput(input, 'SIP trunk update') + const merged: Record = { + ...body, + ...(flags.enabled === undefined ? {} : {enabled: flags.enabled}), + ...(flags.expectedRevision === undefined + ? {} + : {expectedRevision: flags.expectedRevision}), + } + if (Object.keys(merged).every((key) => key === 'expectedRevision')) { + throw new PolymorfaConfigurationError( + 'Provide --input, --enable, or --disable with the change to make.', + 'input', + ) + } + const response = await client.sipTrunks.update( + validateTrunkId(trunkId), + merged, + ) + return result(response.data, response.metadata) +} + +export async function deleteSipTrunk( + client: PlatformClient, + trunkId: string, +): Promise> { + const response = await client.sipTrunks.delete(validateTrunkId(trunkId)) + return result(response.data, response.metadata) +} + +export async function rotateSipTrunkCredentials( + client: PlatformClient, + trunkId: string, +): Promise> { + const response = await client.sipTrunks.rotateCredentials( + validateTrunkId(trunkId), + ) + return result(response.data, response.metadata) +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +export function renderSipTrunks(trunks: readonly SipTrunk[]): string { + if (trunks.length === 0) return 'No SIP trunks found.' + return trunks + .map((trunk) => + [ + trunk.id, + trunk.name, + trunk.direction, + trunk.enabled ? 'enabled' : 'disabled', + trunk.outbound?.targetUri ?? '-', + ].join('\t'), + ) + .join('\n') +} + +export function renderSipTrunk(trunk: SipTrunk): string { + const lines = [ + `Trunk\t${trunk.id}`, + `Name\t${trunk.name}`, + `Project\t${trunk.projectId}`, + `Direction\t${trunk.direction}`, + `Enabled\t${yesNo(trunk.enabled)}`, + `Codecs\t${trunk.codecs.join(', ')}`, + `Max calls\t${trunk.maxConcurrentCalls}`, + `Revision\t${trunk.revision}`, + ] + if (trunk.outbound !== null) { + lines.push( + `Target URI\t${trunk.outbound.targetUri}`, + `Transport\t${trunk.outbound.transport}`, + `Auth username\t${trunk.outbound.authUsername ?? '-'}`, + `Password stored\t${yesNo(trunk.outbound.hasPassword)}`, + `From user\t${trunk.outbound.fromUser ?? 'whatsapp'}`, + ) + } + if (trunk.inbound !== null) { + lines.push( + `Inbound username\t${trunk.inbound.username}`, + `Realm\t${trunk.inbound.realm}`, + `Calling session\t${trunk.inbound.session ?? 'not set (calls refused)'}`, + `Allowed addresses\t${trunk.inbound.allowedAddresses.join(', ')}`, + `Allowed destinations\t${ + trunk.inbound.allowedDestinations.length === 0 + ? 'any' + : trunk.inbound.allowedDestinations.join(', ') + }`, + ) + } + lines.push(`Updated\t${trunk.updatedAt}`) + return lines.join('\n') +} + +export function renderSipTrunkCreated(created: SipTrunkCreated): string { + const trunk = renderSipTrunk(created.trunk) + return created.inboundCredentials === undefined + ? trunk + : `${trunk}\n\n${renderSipTrunkCredentials(created.inboundCredentials)}` +} + +export function renderSipTrunkCredentials( + credentials: SipTrunkCredentials, +): string { + return [ + `Username\t${credentials.username}`, + `Password\t${credentials.password}`, + `Realm\t${credentials.realm}`, + 'Store the password now; it cannot be shown again.', + ].join('\n') +} + +export function renderSipTrunkDeleted(deleted: SipTrunkDeleted): string { + return `Deleted SIP trunk ${deleted.id}.` +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function validateTrunkId(trunkId: string): string { + const trimmed = trunkId.trim() + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + trimmed, + ) + ) { + throw new PolymorfaConfigurationError( + 'SIP trunk ID must be a UUID.', + 'trunk', + ) + } + return trimmed +} + +function objectInput(input: unknown, label: string): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new PolymorfaValidationError( + `${label} input must contain one JSON object.`, + {code: 'invalid_sip_trunk_input'}, + ) + } + return input as Record +} + +function yesNo(value: boolean): string { + return value ? 'yes' : 'no' +} + +function result(data: T, metadata: ResponseMetadata): SipTrunkResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 284b38fb..a45b34c5 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -2,6 +2,7 @@ import {runCommand} from '@oclif/test' import {afterEach, describe, expect, test} from 'vitest' import { + startHttpSequenceServer, startHttpServer, withEnvironment, type ResponseFixture, @@ -11,6 +12,7 @@ import { const API_KEY = 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const CLIENT_TOKEN = 'pmfa_ct_browser_token' +const TRUNK_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' describe('unified calls commands', () => { let server: TestHttpServer | undefined @@ -327,7 +329,7 @@ describe('unified calls commands', () => { expect(viewed.error).toBeUndefined() expect(viewed.stdout).toBe( - 'Session\tsupport/main\nInclude self audio\tyes\nUpdated\t2026-09-16T10:00:00.000Z\n', + 'Session\tsupport/main\nInclude self audio\tyes\nIncoming calls\tclients\nUpdated\t2026-09-16T10:00:00.000Z\n', ) expect(updated.error).toBeUndefined() expect(jsonData(updated.stdout)).toEqual({ @@ -335,12 +337,108 @@ describe('unified calls commands', () => { updatedAt: '2026-09-16T10:00:00.000Z', }) expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/sessions/support%2Fmain/call-settings', 'GET /platform/sessions/support%2Fmain/call-settings', 'PUT /platform/sessions/support%2Fmain/call-settings', ]) + expect(JSON.parse(server.requests[2]?.body ?? '')).toEqual({ + includeSelfAudio: true, + }) + }) + + test('keeps the SIP route when only self audio changes', async () => { + const routed = { + includeSelfAudio: false, + inboundRoute: 'sip_trunk', + sipClaim: false, + sipTrunkId: TRUNK_ID, + updatedAt: '2026-09-16T10:00:00.000Z', + } + server = await startHttpSequenceServer([ + {body: {data: routed, success: true}}, + {body: {data: {...routed, includeSelfAudio: true}, success: true}}, + ]) + + const updated = await apiKey(() => + runCommand( + 'calls settings set --session support --include-self-audio=true', + ), + ) + + expect(updated.error).toBeUndefined() expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ includeSelfAudio: true, + inboundRoute: 'sip_trunk', + sipClaim: false, + sipTrunkId: TRUNK_ID, }) + expect(updated.stdout).toContain('Incoming calls\tclients and SIP trunk') + expect(updated.stdout).toContain(`SIP trunk\t${TRUNK_ID}`) + expect(updated.stdout).toContain('SIP answer claims call\tno') + }) + + test('routes incoming calls to a trunk and back to clients', async () => { + const defaults = { + includeSelfAudio: true, + inboundRoute: 'clients', + sipClaim: true, + sipTrunkId: null, + updatedAt: null, + } + server = await startHttpSequenceServer([ + {body: {data: defaults, success: true}}, + {body: {data: defaults, success: true}}, + ]) + + const routed = await apiKey(() => + runCommand( + `calls settings set --session support --sip-trunk ${TRUNK_ID}`, + ), + ) + const back = await apiKey(() => + runCommand( + 'calls settings set --session support --inbound-route clients --sip-claim=false', + ), + ) + const missing = await apiKey(() => + runCommand( + 'calls settings set --session support --inbound-route sip_trunk', + ), + ) + const conflicting = await apiKey(() => + runCommand( + `calls settings set --session support --inbound-route clients --sip-trunk ${TRUNK_ID}`, + ), + ) + const empty = await apiKey(() => + runCommand('calls settings set --session support'), + ) + + expect(routed.error).toBeUndefined() + expect(back.error).toBeUndefined() + expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + includeSelfAudio: true, + inboundRoute: 'sip_trunk', + sipClaim: true, + sipTrunkId: TRUNK_ID, + }) + expect(JSON.parse(server.requests[3]?.body ?? '')).toEqual({ + includeSelfAudio: true, + inboundRoute: 'clients', + sipClaim: false, + }) + expect(exitCode(missing.error)).toBe(2) + expect(missing.stderr).toContain('--sip-trunk') + expect(exitCode(conflicting.error)).toBe(2) + expect(exitCode(empty.error)).toBe(2) + // The missing-trunk check reads first; the other refusals send nothing. + expect(server.requests.map(({method}) => method)).toEqual([ + 'GET', + 'PUT', + 'GET', + 'PUT', + 'GET', + ]) }) test('rejects client tokens and invalid values for call settings', async () => { diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts new file mode 100644 index 00000000..1a97db2b --- /dev/null +++ b/test/commands/sip-trunks.test.ts @@ -0,0 +1,232 @@ +import {runCommand} from '@oclif/test' +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROJECT = '018f0000-0000-7000-8000-000000000002' +const TRUNK_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' + +const trunk = { + id: TRUNK_ID, + projectId: PROJECT, + name: 'Head office PBX', + enabled: true, + direction: 'both', + outbound: { + targetUri: 'sips:pbx.example.com', + transport: 'tls', + authUsername: 'polymorfa', + hasPassword: true, + fromUser: null, + }, + inbound: { + username: 'pmfa_abc', + realm: 'sip.example.test', + session: null, + allowedAddresses: ['203.0.113.10/32'], + allowedDestinations: [], + }, + codecs: ['PCMA', 'PCMU'], + maxConcurrentCalls: 30, + revision: 3, + createdAt: '2026-09-17T10:00:00.000Z', + updatedAt: '2026-09-17T10:00:00.000Z', +} + +describe('sip-trunk commands', () => { + let server: TestHttpServer | undefined + let directory: string | undefined + + afterEach(async () => { + await server?.close() + server = undefined + if (directory !== undefined) + await rm(directory, {force: true, recursive: true}) + directory = undefined + }) + + test('lists and shows trunks for a project', async () => { + server = await startHttpSequenceServer([ + {body: {data: [trunk], success: true}}, + {body: {data: trunk, success: true}}, + ]) + + const listed = await apiKey(() => + runCommand(`sip-trunk list --project ${PROJECT}`), + ) + const viewed = await apiKey(() => runCommand(`sip-trunk view ${TRUNK_ID}`)) + const missing = await apiKey(() => runCommand('sip-trunk list')) + + expect(listed.error).toBeUndefined() + expect(listed.stdout).toBe( + `${TRUNK_ID}\tHead office PBX\tboth\tenabled\tsips:pbx.example.com\n`, + ) + expect(viewed.stdout).toContain('Password stored\tyes') + expect(viewed.stdout).toContain('Calling session\tnot set (calls refused)') + expect(viewed.stdout).toContain('Allowed destinations\tany') + expect(viewed.stdout).toContain('Revision\t3') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/sip-trunks?projectId=${PROJECT}`, + `GET /platform/sip-trunks/${TRUNK_ID}`, + ]) + expect(exitCode(missing.error)).toBe(2) + }) + + test('creates a trunk from a file and prints the one-time password', async () => { + server = await startHttpServer({ + body: { + data: { + inboundCredentials: { + password: 'one-time-secret', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + trunk, + }, + success: true, + }, + status: 201, + }) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-sip-')) + const input = join(directory, 'trunk.json') + const body = { + direction: 'both', + inbound: {allowedAddresses: ['203.0.113.10']}, + name: 'Head office PBX', + outbound: { + authPassword: 'pbx-secret', + authUsername: 'polymorfa', + targetUri: 'sips:pbx.example.com', + transport: 'tls', + }, + } + await writeFile(input, JSON.stringify(body)) + + const created = await apiKey(() => + runCommand(`sip-trunk create --project ${PROJECT} --input ${input}`), + ) + const withProject = join(directory, 'with-project.json') + await writeFile(withProject, JSON.stringify({...body, projectId: PROJECT})) + const refused = await apiKey(() => + runCommand( + `sip-trunk create --project ${PROJECT} --input ${withProject}`, + ), + ) + + expect(created.error).toBeUndefined() + expect(created.stdout).toContain('Password\tone-time-secret') + expect(created.stdout).toContain('cannot be shown again') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + ...body, + projectId: PROJECT, + }) + expect(exitCode(refused.error)).toBe(2) + expect(server.requests).toHaveLength(1) + }) + + test('disables a trunk with a revision guard and reports conflicts', async () => { + server = await startHttpSequenceServer([ + {body: {data: {...trunk, enabled: false, revision: 4}, success: true}}, + { + body: { + error: { + code: 'sip_trunk_revision_conflict', + message: 'The trunk changed.', + }, + success: false, + }, + status: 409, + }, + ]) + + const disabled = await apiKey(() => + runCommand( + `sip-trunk update ${TRUNK_ID} --disable --expected-revision 3`, + ), + ) + const stale = await apiKey(() => + runCommand(`sip-trunk update ${TRUNK_ID} --enable --expected-revision 3`), + ) + const nothing = await apiKey(() => + runCommand(`sip-trunk update ${TRUNK_ID} --expected-revision 3`), + ) + const badId = await apiKey(() => + runCommand('sip-trunk update nope --enable'), + ) + + expect(disabled.error).toBeUndefined() + expect(disabled.stdout).toContain('Enabled\tno') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + enabled: false, + expectedRevision: 3, + }) + expect(server.requests[0]?.method).toBe('PATCH') + expect(exitCode(stale.error)).toBe(4) + expect(stale.stderr).toContain('The trunk changed.') + expect(exitCode(nothing.error)).toBe(2) + expect(exitCode(badId.error)).toBe(2) + expect(server.requests).toHaveLength(2) + }) + + test('requires confirmation to delete or rotate', async () => { + server = await startHttpSequenceServer([ + {body: {data: {deleted: true, id: TRUNK_ID}, success: true}}, + { + body: { + data: { + password: 'rotated', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + success: true, + }, + }, + ]) + + const unconfirmed = await apiKey(() => + runCommand(`sip-trunk delete ${TRUNK_ID}`), + ) + const deleted = await apiKey(() => + runCommand(`sip-trunk delete ${TRUNK_ID} --yes`), + ) + const rotated = await apiKey(() => + runCommand(`sip-trunk rotate-credentials ${TRUNK_ID} --yes --json`), + ) + + expect(exitCode(unconfirmed.error)).toBe(2) + expect(deleted.stdout).toBe(`Deleted SIP trunk ${TRUNK_ID}.\n`) + expect( + (JSON.parse(rotated.stdout) as {data: {password: string}}).data.password, + ).toBe('rotated') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `DELETE /platform/sip-trunks/${TRUNK_ID}`, + `POST /platform/sip-trunks/${TRUNK_ID}/credentials`, + ]) + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} From 9096990a5541c3b72fcc612b1fb16255b6c1976a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 01:37:52 +0300 Subject: [PATCH 04/34] chore(deps): pin the SDK commit with case-insensitive trunk scoping Use a valid project ID in the SIP trunk examples. --- README.md | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2fd13522..e5938668 100644 --- a/README.md +++ b/README.md @@ -460,9 +460,9 @@ SIP trunks connect a PBX to WhatsApp calls. They are a beta: changes fail with exit code 3 until your team is enrolled. Commands require a server API key. ```shell -polymorfa sip-trunk list --project project_123 +polymorfa sip-trunk list --project 018f0000-0000-7000-8000-000000000002 polymorfa sip-trunk view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 -polymorfa sip-trunk create --project project_123 --input trunk.json +polymorfa sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ --disable --expected-revision 3 polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --input changes.json diff --git a/package.json b/package.json index 6681cc03..06d3ca3d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#54d2518eb1e935c2d0f530d8c0d6ec1fb0f0f001" + "@polymorfa/sdk": "github:polymorfa/sdks#b5f776fc7d2bec0c1fe07216d654dbad9ad0dc4d" }, "devDependencies": { "@eslint/js": "^9.39.5", From 2ea8bbd5642c4fa51292e1e858ae627ca05d5f47 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 01:53:34 +0300 Subject: [PATCH 05/34] feat(calls): refuse settings changes that lost to another update calls settings set sends the revision it read and exits 4 on a conflict. Pin SDK ca01925 (unpushed; lockfile not regenerated). --- README.md | 4 +++- package.json | 2 +- src/services/calls.ts | 7 ++++++- test/commands/calls.test.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e5938668..920726dd 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,9 @@ never answers or declines on its own. `--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a SIP trunk; `--sip-claim` controls whether the trunk's answer claims the call. `calls settings set` reads the current settings and sends them back - with your changes, so a flag you omit keeps its value. + with your changes and the revision it read, so a flag you omit keeps its + value. If the settings change in between, the command exits with code 4; + run it again. ### SIP trunks diff --git a/package.json b/package.json index 06d3ca3d..491db2bf 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#b5f776fc7d2bec0c1fe07216d654dbad9ad0dc4d" + "@polymorfa/sdk": "github:polymorfa/sdks#ca01925e59f17aa8713d3d9221fa0b4c35367349" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/src/services/calls.ts b/src/services/calls.ts index b2393361..bda89cba 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -260,7 +260,8 @@ export interface CallSettingsChanges { /** * Apply the given changes to the session's current settings. The API replaces - * every field on update, so unchanged fields are read first and sent back. + * every field on update, so unchanged fields are read first and sent back + * with the revision they were read at. */ export async function updateCallSettings( client: MessagingClient, @@ -299,6 +300,10 @@ export async function updateCallSettings( inboundRoute, sipClaim: changes.sipClaim ?? current.sipClaim, ...(sipTrunkId === null ? {} : {sipTrunkId}), + // Fails with a conflict if the settings changed after they were read. + ...(current.revision === undefined + ? {} + : {expectedRevision: current.revision}), }) return result(response.data.data, response.metadata) } diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index a45b34c5..1f6171bd 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -350,6 +350,7 @@ describe('unified calls commands', () => { const routed = { includeSelfAudio: false, inboundRoute: 'sip_trunk', + revision: 5, sipClaim: false, sipTrunkId: TRUNK_ID, updatedAt: '2026-09-16T10:00:00.000Z', @@ -367,6 +368,7 @@ describe('unified calls commands', () => { expect(updated.error).toBeUndefined() expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + expectedRevision: 5, includeSelfAudio: true, inboundRoute: 'sip_trunk', sipClaim: false, @@ -381,6 +383,7 @@ describe('unified calls commands', () => { const defaults = { includeSelfAudio: true, inboundRoute: 'clients', + revision: 0, sipClaim: true, sipTrunkId: null, updatedAt: null, @@ -417,12 +420,14 @@ describe('unified calls commands', () => { expect(routed.error).toBeUndefined() expect(back.error).toBeUndefined() expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + expectedRevision: 0, includeSelfAudio: true, inboundRoute: 'sip_trunk', sipClaim: true, sipTrunkId: TRUNK_ID, }) expect(JSON.parse(server.requests[3]?.body ?? '')).toEqual({ + expectedRevision: 0, includeSelfAudio: true, inboundRoute: 'clients', sipClaim: false, @@ -441,6 +446,30 @@ describe('unified calls commands', () => { ]) }) + test('exits with a conflict when the settings changed after the read', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: {includeSelfAudio: false, revision: 2, updatedAt: null}, + success: true, + }, + }, + apiError(409, 'state_conflict', 'The call settings changed.'), + ]) + + const result = await apiKey(() => + runCommand( + 'calls settings set --session support --include-self-audio=true', + ), + ) + + expect(exitCode(result.error)).toBe(4) + expect(result.stderr).toContain('The call settings changed.') + expect(JSON.parse(server.requests[1]?.body ?? '')).toMatchObject({ + expectedRevision: 2, + }) + }) + test('rejects client tokens and invalid values for call settings', async () => { server = await startHttpServer({body: {success: true}}) From 47169d37dabe7e2bf81d6d2e62cb58a0c631651e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 02:04:55 +0300 Subject: [PATCH 06/34] feat(calls): send only the call settings given The API now keeps omitted settings, so calls settings set no longer reads first. Pin SDK dae9004 (unpushed; lockfile not regenerated). --- README.md | 6 +- package.json | 2 +- src/commands/calls/settings/set.ts | 2 +- src/services/calls.ts | 33 +++------ test/commands/calls.test.ts | 115 +++++++++-------------------- 5 files changed, 51 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 920726dd..052ee3dc 100644 --- a/README.md +++ b/README.md @@ -451,10 +451,8 @@ never answers or declines on its own. connection receives includes its own audio. It is off by default. `--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a SIP trunk; `--sip-claim` controls whether the trunk's answer claims the - call. `calls settings set` reads the current settings and sends them back - with your changes and the revision it read, so a flag you omit keeps its - value. If the settings change in between, the command exits with code 4; - run it again. + call. `calls settings set` sends only the flags you pass; the other + settings keep their values. ### SIP trunks diff --git a/package.json b/package.json index 491db2bf..f9b6e11d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#ca01925e59f17aa8713d3d9221fa0b4c35367349" + "@polymorfa/sdk": "github:polymorfa/sdks#dae900480034c65fc198e93e63df7712a1f4cbb8" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts index 429de8fd..c065e422 100644 --- a/src/commands/calls/settings/set.ts +++ b/src/commands/calls/settings/set.ts @@ -9,7 +9,7 @@ import { export default class CallsSettingsSet extends BaseCommand { static override description = - 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' + 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' static override examples = [ '<%= config.bin %> calls settings set --session support --include-self-audio=true', diff --git a/src/services/calls.ts b/src/services/calls.ts index bda89cba..d957e974 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -259,9 +259,8 @@ export interface CallSettingsChanges { } /** - * Apply the given changes to the session's current settings. The API replaces - * every field on update, so unchanged fields are read first and sent back - * with the revision they were read at. + * Change the given settings. The API keeps every setting that is not sent, + * so no read is needed first. */ export async function updateCallSettings( client: MessagingClient, @@ -281,29 +280,19 @@ export async function updateCallSettings( 'sip-trunk', ) } - const current = (await client.voip.retrieveCallSettings(name)).data.data + // A trunk alone switches the route to it. const inboundRoute = changes.inboundRoute ?? - (changes.sipTrunkId === undefined ? current.inboundRoute : 'sip_trunk') - const sipTrunkId = - inboundRoute === 'sip_trunk' - ? (changes.sipTrunkId ?? current.sipTrunkId) - : null - if (inboundRoute === 'sip_trunk' && sipTrunkId === null) { - throw new PolymorfaConfigurationError( - 'Provide --sip-trunk with the trunk that receives incoming calls.', - 'sip-trunk', - ) - } + (changes.sipTrunkId === undefined ? undefined : 'sip_trunk') const response = await client.voip.updateCallSettings(name, { - includeSelfAudio: changes.includeSelfAudio ?? current.includeSelfAudio, - inboundRoute, - sipClaim: changes.sipClaim ?? current.sipClaim, - ...(sipTrunkId === null ? {} : {sipTrunkId}), - // Fails with a conflict if the settings changed after they were read. - ...(current.revision === undefined + ...(changes.includeSelfAudio === undefined + ? {} + : {includeSelfAudio: changes.includeSelfAudio}), + ...(inboundRoute === undefined ? {} : {inboundRoute}), + ...(changes.sipTrunkId === undefined ? {} - : {expectedRevision: current.revision}), + : {sipTrunkId: changes.sipTrunkId}), + ...(changes.sipClaim === undefined ? {} : {sipClaim: changes.sipClaim}), }) return result(response.data.data, response.metadata) } diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 1f6171bd..e29bf964 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -2,7 +2,6 @@ import {runCommand} from '@oclif/test' import {afterEach, describe, expect, test} from 'vitest' import { - startHttpSequenceServer, startHttpServer, withEnvironment, type ResponseFixture, @@ -337,63 +336,31 @@ describe('unified calls commands', () => { updatedAt: '2026-09-16T10:00:00.000Z', }) expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ - 'GET /platform/sessions/support%2Fmain/call-settings', 'GET /platform/sessions/support%2Fmain/call-settings', 'PUT /platform/sessions/support%2Fmain/call-settings', ]) - expect(JSON.parse(server.requests[2]?.body ?? '')).toEqual({ + expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ includeSelfAudio: true, }) }) - test('keeps the SIP route when only self audio changes', async () => { + test('sends only the settings it is given', async () => { const routed = { - includeSelfAudio: false, + includeSelfAudio: true, inboundRoute: 'sip_trunk', revision: 5, sipClaim: false, sipTrunkId: TRUNK_ID, updatedAt: '2026-09-16T10:00:00.000Z', } - server = await startHttpSequenceServer([ - {body: {data: routed, success: true}}, - {body: {data: {...routed, includeSelfAudio: true}, success: true}}, - ]) + server = await startHttpServer({body: {data: routed, success: true}}) - const updated = await apiKey(() => + const selfAudio = await apiKey(() => runCommand( 'calls settings set --session support --include-self-audio=true', ), ) - - expect(updated.error).toBeUndefined() - expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ - expectedRevision: 5, - includeSelfAudio: true, - inboundRoute: 'sip_trunk', - sipClaim: false, - sipTrunkId: TRUNK_ID, - }) - expect(updated.stdout).toContain('Incoming calls\tclients and SIP trunk') - expect(updated.stdout).toContain(`SIP trunk\t${TRUNK_ID}`) - expect(updated.stdout).toContain('SIP answer claims call\tno') - }) - - test('routes incoming calls to a trunk and back to clients', async () => { - const defaults = { - includeSelfAudio: true, - inboundRoute: 'clients', - revision: 0, - sipClaim: true, - sipTrunkId: null, - updatedAt: null, - } - server = await startHttpSequenceServer([ - {body: {data: defaults, success: true}}, - {body: {data: defaults, success: true}}, - ]) - - const routed = await apiKey(() => + const trunk = await apiKey(() => runCommand( `calls settings set --session support --sip-trunk ${TRUNK_ID}`, ), @@ -403,7 +370,7 @@ describe('unified calls commands', () => { 'calls settings set --session support --inbound-route clients --sip-claim=false', ), ) - const missing = await apiKey(() => + const keepTrunk = await apiKey(() => runCommand( 'calls settings set --session support --inbound-route sip_trunk', ), @@ -417,57 +384,47 @@ describe('unified calls commands', () => { runCommand('calls settings set --session support'), ) - expect(routed.error).toBeUndefined() - expect(back.error).toBeUndefined() - expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ - expectedRevision: 0, - includeSelfAudio: true, - inboundRoute: 'sip_trunk', - sipClaim: true, - sipTrunkId: TRUNK_ID, - }) - expect(JSON.parse(server.requests[3]?.body ?? '')).toEqual({ - expectedRevision: 0, - includeSelfAudio: true, - inboundRoute: 'clients', - sipClaim: false, - }) - expect(exitCode(missing.error)).toBe(2) - expect(missing.stderr).toContain('--sip-trunk') - expect(exitCode(conflicting.error)).toBe(2) - expect(exitCode(empty.error)).toBe(2) - // The missing-trunk check reads first; the other refusals send nothing. + for (const ok of [selfAudio, trunk, back, keepTrunk]) { + expect(ok.error).toBeUndefined() + } + expect(selfAudio.stdout).toContain('Incoming calls\tclients and SIP trunk') + expect(selfAudio.stdout).toContain(`SIP trunk\t${TRUNK_ID}`) + expect(selfAudio.stdout).toContain('SIP answer claims call\tno') expect(server.requests.map(({method}) => method)).toEqual([ - 'GET', 'PUT', - 'GET', 'PUT', - 'GET', + 'PUT', + 'PUT', ]) + expect( + server.requests.map(({body}) => JSON.parse(body) as unknown), + ).toEqual([ + {includeSelfAudio: true}, + {inboundRoute: 'sip_trunk', sipTrunkId: TRUNK_ID}, + {inboundRoute: 'clients', sipClaim: false}, + {inboundRoute: 'sip_trunk'}, + ]) + expect(exitCode(conflicting.error)).toBe(2) + expect(exitCode(empty.error)).toBe(2) }) - test('exits with a conflict when the settings changed after the read', async () => { - server = await startHttpSequenceServer([ - { - body: { - data: {includeSelfAudio: false, revision: 2, updatedAt: null}, - success: true, - }, - }, - apiError(409, 'state_conflict', 'The call settings changed.'), - ]) + test('reports an API refusal of the change', async () => { + server = await startHttpServer( + apiError( + 400, + 'invalid_parameter', + 'sipTrunkId is required when inboundRoute is sip_trunk.', + ), + ) const result = await apiKey(() => runCommand( - 'calls settings set --session support --include-self-audio=true', + 'calls settings set --session support --inbound-route sip_trunk', ), ) - expect(exitCode(result.error)).toBe(4) - expect(result.stderr).toContain('The call settings changed.') - expect(JSON.parse(server.requests[1]?.body ?? '')).toMatchObject({ - expectedRevision: 2, - }) + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('sipTrunkId is required') }) test('rejects client tokens and invalid values for call settings', async () => { From 551d230ab4e56a549bf4fe27ec52b841c5113020 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 02:21:48 +0300 Subject: [PATCH 07/34] feat(calls): turn calling off for a session Add --calls-enabled to calls settings set and show the switch in settings output. Pin SDK fa1cb37 (unpushed; lockfile not regenerated). --- README.md | 5 +++- package.json | 2 +- src/commands/calls/settings/set.ts | 10 +++++++- src/services/calls.ts | 7 +++++- test/commands/calls.test.ts | 38 +++++++++++++++++++++++++++++- 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 052ee3dc..fddbdf65 100644 --- a/README.md +++ b/README.md @@ -447,7 +447,10 @@ never answers or declines on its own. end, invite, watch, and carry audio when the session's client rules allow it. The API decides; the CLI reports its errors unchanged. - **Settings.** `calls settings` uses the Platform API and requires a server - API key. `--include-self-audio` controls whether the merged audio a + API key. `--calls-enabled=false` turns calling off for the session: placing, + answering and joining calls then exit with code 3 (`calls_disabled`), + incoming calls are declined, and calls in progress continue. + `--include-self-audio` controls whether the merged audio a connection receives includes its own audio. It is off by default. `--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a SIP trunk; `--sip-claim` controls whether the trunk's answer claims the diff --git a/package.json b/package.json index f9b6e11d..9961b8b3 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#dae900480034c65fc198e93e63df7712a1f4cbb8" + "@polymorfa/sdk": "github:polymorfa/sdks#fa1cb37c253eba373cb80fdbabaf3002afecb079" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts index c065e422..057da6aa 100644 --- a/src/commands/calls/settings/set.ts +++ b/src/commands/calls/settings/set.ts @@ -9,15 +9,20 @@ import { export default class CallsSettingsSet extends BaseCommand { static override description = - 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' + 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' static override examples = [ + '<%= config.bin %> calls settings set --session support --calls-enabled=false', '<%= config.bin %> calls settings set --session support --include-self-audio=true', '<%= config.bin %> calls settings set --session support --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', '<%= config.bin %> calls settings set --session support --inbound-route clients', ] static override flags = { + 'calls-enabled': Flags.string({ + description: 'Allow calls on the session (false turns calling off).', + options: ['true', 'false'], + }), 'include-self-audio': Flags.string({ description: 'Include each connection’s own audio in the merged audio it receives.', @@ -47,6 +52,9 @@ export default class CallsSettingsSet extends BaseCommand { createCallSettingsClient(await this.platformEnvironment(flags)), flags.session, { + ...(flags['calls-enabled'] === undefined + ? {} + : {callsEnabled: flags['calls-enabled'] === 'true'}), ...(flags['include-self-audio'] === undefined ? {} : {includeSelfAudio: flags['include-self-audio'] === 'true'}), diff --git a/src/services/calls.ts b/src/services/calls.ts index d957e974..d6c5e1d8 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -252,6 +252,7 @@ export async function viewCallSettings( } export interface CallSettingsChanges { + readonly callsEnabled?: boolean readonly includeSelfAudio?: boolean readonly inboundRoute?: CallInboundRoute readonly sipClaim?: boolean @@ -271,7 +272,7 @@ export async function updateCallSettings( if (Object.values(changes).every((value) => value === undefined)) { throw new PolymorfaConfigurationError( 'Provide at least one setting to change.', - 'include-self-audio', + 'calls-enabled', ) } if (changes.inboundRoute === 'clients' && changes.sipTrunkId !== undefined) { @@ -285,6 +286,9 @@ export async function updateCallSettings( changes.inboundRoute ?? (changes.sipTrunkId === undefined ? undefined : 'sip_trunk') const response = await client.voip.updateCallSettings(name, { + ...(changes.callsEnabled === undefined + ? {} + : {callsEnabled: changes.callsEnabled}), ...(changes.includeSelfAudio === undefined ? {} : {includeSelfAudio: changes.includeSelfAudio}), @@ -381,6 +385,7 @@ export function renderCallSettings( ): string { return [ `Session\t${session}`, + `Calls\t${settings.callsEnabled === false ? 'off' : 'on'}`, `Include self audio\t${yesNo(settings.includeSelfAudio)}`, `Incoming calls\t${settings.inboundRoute === 'sip_trunk' ? 'clients and SIP trunk' : 'clients'}`, ...(settings.inboundRoute === 'sip_trunk' diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index e29bf964..b359f67b 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -2,6 +2,7 @@ import {runCommand} from '@oclif/test' import {afterEach, describe, expect, test} from 'vitest' import { + startHttpSequenceServer, startHttpServer, withEnvironment, type ResponseFixture, @@ -328,7 +329,7 @@ describe('unified calls commands', () => { expect(viewed.error).toBeUndefined() expect(viewed.stdout).toBe( - 'Session\tsupport/main\nInclude self audio\tyes\nIncoming calls\tclients\nUpdated\t2026-09-16T10:00:00.000Z\n', + 'Session\tsupport/main\nCalls\ton\nInclude self audio\tyes\nIncoming calls\tclients\nUpdated\t2026-09-16T10:00:00.000Z\n', ) expect(updated.error).toBeUndefined() expect(jsonData(updated.stdout)).toEqual({ @@ -408,6 +409,41 @@ describe('unified calls commands', () => { expect(exitCode(empty.error)).toBe(2) }) + test('turns calling off and reports calls_disabled refusals', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + callsEnabled: false, + includeSelfAudio: false, + inboundRoute: 'clients', + revision: 1, + sipClaim: true, + sipTrunkId: null, + updatedAt: '2026-09-17T10:00:00.000Z', + }, + success: true, + }, + }, + apiError(403, 'calls_disabled', 'Calling is turned off for this number.'), + ]) + + const off = await apiKey(() => + runCommand('calls settings set --session support --calls-enabled=false'), + ) + const place = await apiKey(() => + runCommand('calls place +15550100 --session support'), + ) + + expect(off.error).toBeUndefined() + expect(off.stdout).toContain('Calls\toff') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + callsEnabled: false, + }) + expect(exitCode(place.error)).toBe(3) + expect(place.stderr).toContain('Calling is turned off for this number.') + }) + test('reports an API refusal of the change', async () => { server = await startHttpServer( apiError( From 730f9bdd967d23b3225777f521d542bcbae6bd7f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 02:34:51 +0300 Subject: [PATCH 08/34] test(calls): exit 3 when calling is turned off for the session Pin SDK 6d02b25 (unpushed; lockfile not regenerated). --- package.json | 2 +- test/commands/calls-sockets.test.ts | 49 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9961b8b3..8a8030a9 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#fa1cb37c253eba373cb80fdbabaf3002afecb079" + "@polymorfa/sdk": "github:polymorfa/sdks#6d02b259ff2292e339ead459d278d0eb54650d5a" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index d56eb3db..3d0159eb 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -415,6 +415,55 @@ describe('calls watch and media', () => { }) }) + test('exits with an authorization error when calling is turned off for the session', async () => { + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-calls-off-')) + const outputPath = join(directory, 'out.pcm') + try { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + if (socket.path.startsWith('/voip/ws')) { + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_4', {from: '+15550100'}) + return + } + socket.sendJson({code: 'calls_disabled', type: 'error'}) + socket.close(4403, 'calls disabled') + }, + rest: () => ({ + body: { + data: { + answered: true, + answeredBy: 'server:default', + exclusive: false, + }, + success: true, + }, + }), + }) + + const result = await apiKey(() => + runCommand( + `calls media call_4 --session support --output ${outputPath} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(3) + const lines = result.stderr.trim().split('\n') + expect(JSON.parse(lines.at(-1)!)).toMatchObject({ + category: 'authorization', + exitCode: 3, + message: 'Calling is turned off for this number.', + }) + // A refused connection is not retried. + expect( + server.sockets.filter((s) => !s.path.startsWith('/voip/ws')), + ).toHaveLength(1) + } finally { + await rm(directory, {force: true, recursive: true}) + } + }) + test('paces input audio in real time and leaves when interrupted', async () => { const controller = new AbortController() let received = 0 From abfea4c23175af36e9c7bc5f041e13708ecc0c87 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 02:38:53 +0300 Subject: [PATCH 09/34] chore(deps): pin SDK efd96e7 The SDK commit only re-pins its contract snapshots; lockfile not regenerated. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a8030a9..a35174db 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#6d02b259ff2292e339ead459d278d0eb54650d5a" + "@polymorfa/sdk": "github:polymorfa/sdks#efd96e7da4c8db82d49fbada6ad1ff3011edaf85" }, "devDependencies": { "@eslint/js": "^9.39.5", From 632f54a0ecb627353d2cc16ecb1362c5c0b1ece7 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 08:21:12 +0300 Subject: [PATCH 10/34] fix(calls): stop calls media when its output file fails An unwritable --output file now leaves the call and exits with code 2 instead of raising an uncaught error, and an early terminal error no longer surfaces as an unhandled rejection. Move the SIP trunks README section after the calls media text and pin SDK 01cf404. --- README.md | 50 +++++++++++++++-------------- package.json | 2 +- src/commands/calls/media.ts | 31 ++++++++++++++++-- src/services/calls.ts | 3 ++ test/commands/calls-sockets.test.ts | 23 +++++++++++++ 5 files changed, 81 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d2039976..0619aa2d 100644 --- a/README.md +++ b/README.md @@ -502,29 +502,6 @@ never answers or declines on its own. call. `calls settings set` sends only the flags you pass; the other settings keep their values. -### SIP trunks - -SIP trunks connect a PBX to WhatsApp calls. They are a beta: changes fail with -exit code 3 until your team is enrolled. Commands require a server API key. - -```shell -polymorfa sip-trunk list --project 018f0000-0000-7000-8000-000000000002 -polymorfa sip-trunk view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 -polymorfa sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json -polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ - --disable --expected-revision 3 -polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --input changes.json -polymorfa sip-trunk rotate-credentials 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes -polymorfa sip-trunk delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes -``` - -`create` and `update` read the request body from a file or stdin (`-`), so -passwords never appear in the command line. `create` refuses a body that names -`projectId`; use `--project`. A trunk that accepts calls from your PBX, and -`rotate-credentials`, print the password once. `update --expected-revision` -exits with code 4 when the trunk changed; `delete` exits with code 4 while a -session routes calls to the trunk. - `calls watch` streams inbound calls and their progress: `incoming`, `claim`, `state`, `participant_joined`, `participant_state`, `participant_left`, and `ended`. Human output prints one tab-separated line per update and status on @@ -544,7 +521,9 @@ participant answered without a claim. `--input ` sends audio in real time, `--output ` receives audio (standard output by default, never a terminal), and `--leave-on-eof` leaves after the input is sent. Status goes to standard error, as JSON lines with `--json`. Ctrl-C leaves the call, or ends a -placed call that is still ringing. The command carries audio only. +placed call that is still ringing. If the `--output` file cannot be written, +the command leaves the call and exits with code 2. The command carries audio +only. Convert audio with any tool that writes raw PCM, for example: @@ -554,6 +533,29 @@ sox greeting.wav -t raw -r 16000 -e signed -b 16 -c 1 - | \ --input - --leave-on-eof > reply.pcm ``` +### SIP trunks + +SIP trunks connect a PBX to WhatsApp calls. They are a beta: changes fail with +exit code 3 until your team is enrolled. Commands require a server API key. + +```shell +polymorfa sip-trunk list --project 018f0000-0000-7000-8000-000000000002 +polymorfa sip-trunk view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 +polymorfa sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json +polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ + --disable --expected-revision 3 +polymorfa sip-trunk update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --input changes.json +polymorfa sip-trunk rotate-credentials 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes +polymorfa sip-trunk delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes +``` + +`create` and `update` read the request body from a file or stdin (`-`), so +passwords never appear in the command line. `create` refuses a body that names +`projectId`; use `--project`. A trunk that accepts calls from your PBX, and +`rotate-credentials`, print the password once. `update --expected-revision` +exits with code 4 when the trunk changed; `delete` exits with code 4 while a +session routes calls to the trunk. + ## Session call rejection and stable identities `call reject` is a separate command for the Linked Device session route. It diff --git a/package.json b/package.json index 4331559f..ac57181e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#efd96e7da4c8db82d49fbada6ad1ff3011edaf85", + "@polymorfa/sdk": "github:polymorfa/sdks#01cf404b7eac4492648e867099328b3f7d8bc376", "zod": "4.2.0" }, "devDependencies": { diff --git a/src/commands/calls/media.ts b/src/commands/calls/media.ts index b4a763e5..ff8d7f25 100644 --- a/src/commands/calls/media.ts +++ b/src/commands/calls/media.ts @@ -92,8 +92,24 @@ export default class CallsMedia extends BaseCommand { else if (!quiet) process.stderr.write(`${human}\n`) } + const stop = stopOnSignals() + // Ctrl-C or a failed --output file stops the call. + const halt = new AbortController() + const onStop = (): void => halt.abort() + stop.signal.addEventListener('abort', onStop, {once: true}) const file: WriteStream | undefined = flags.output === '-' ? undefined : createWriteStream(flags.output) + // Listen before the stream can fail or close: its errors are asynchronous. + // `once` rejects on the stream's error, which is recorded below. + const fileClosed = + file === undefined + ? undefined + : once(file, 'close').catch(() => undefined) + let fileError: Error | undefined + file?.on('error', (error: Error) => { + fileError ??= error + halt.abort() + }) const output = (pcm: Uint8Array): void => { if (file === undefined) process.stdout.write(pcm) else file.write(pcm) @@ -105,7 +121,6 @@ export default class CallsMedia extends BaseCommand { ? process.stdin : createReadStream(flags.input) - const stop = stopOnSignals() try { const summary = await bridgeCallMedia( { @@ -119,24 +134,27 @@ export default class CallsMedia extends BaseCommand { participant: flags.participant, place: flags.place, session: flags.session, - signal: stop.signal, + signal: halt.signal, video: flags.video, }, (status) => report(status, renderMediaStatus(status, flags.session)), ) + if (fileError !== undefined) throw outputFailed(flags.output, fileError) report( {type: 'finished', ...summary}, `Finished (${summary.reason}): received ${summary.bytesReceived} bytes, sent ${summary.bytesSent} bytes.`, ) } finally { + stop.signal.removeEventListener('abort', onStop) stop.dispose() if (input !== undefined && input !== process.stdin) input.destroy() if (input === process.stdin) process.stdin.pause() if (file !== undefined) { file.end() - await once(file, 'close') + await fileClosed } } + if (fileError !== undefined) throw outputFailed(flags.output, fileError) } } @@ -160,3 +178,10 @@ function renderMediaStatus( return renderCallActivity(status) } } + +function outputFailed(path: string, error: Error): PolymorfaConfigurationError { + return new PolymorfaConfigurationError( + `Could not write received audio to ${path}: ${error.message}`, + 'output', + ) +} diff --git a/src/services/calls.ts b/src/services/calls.ts index d6c5e1d8..f14561be 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -799,6 +799,9 @@ export async function bridgeCallMedia( resolveDone = resolve rejectDone = reject }) + // A terminal error can settle `done` before `connect()` returns and the + // function awaits it. + done.catch(() => undefined) const summary = (reason: CallMediaSummary['reason']): CallMediaSummary => ({ bytesReceived, bytesSent, diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index fac2fcb1..aee8350b 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -463,6 +463,29 @@ describe('calls watch and media', () => { } }) + test('stops and exits with a configuration error when --output cannot be written', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({participant: 'server:default', type: 'ready'}) + }, + }) + + const result = await apiKey(() => + runCommand( + `calls media --session support --output ${join(tmpdir(), 'polymorfa-missing-dir', 'nested', 'out.pcm')} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + const lines = result.stderr.trim().split('\n') + expect(JSON.parse(lines.at(-1)!)).toMatchObject({ + error: {category: 'configuration'}, + }) + expect(result.stderr).toContain('Could not write received audio') + expect(result.stderr).not.toContain('"finished"') + }) + test('paces input audio in real time and leaves when interrupted', async () => { const controller = new AbortController() let received = 0 From 4d74e3f00a1584b54c48db3b2606ec023e6289fc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 08:31:25 +0300 Subject: [PATCH 11/34] fix(calls): close the calls media output before reporting completion --- src/commands/calls/media.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/calls/media.ts b/src/commands/calls/media.ts index ff8d7f25..e75c5dac 100644 --- a/src/commands/calls/media.ts +++ b/src/commands/calls/media.ts @@ -121,6 +121,15 @@ export default class CallsMedia extends BaseCommand { ? process.stdin : createReadStream(flags.input) + // Flush and close the output file once; its errors arrive asynchronously. + let outputClosed = false + const closeOutput = async (): Promise => { + if (file === undefined || outputClosed) return + outputClosed = true + file.end() + await fileClosed + } + try { const summary = await bridgeCallMedia( { @@ -139,6 +148,7 @@ export default class CallsMedia extends BaseCommand { }, (status) => report(status, renderMediaStatus(status, flags.session)), ) + await closeOutput() if (fileError !== undefined) throw outputFailed(flags.output, fileError) report( {type: 'finished', ...summary}, @@ -149,10 +159,7 @@ export default class CallsMedia extends BaseCommand { stop.dispose() if (input !== undefined && input !== process.stdin) input.destroy() if (input === process.stdin) process.stdin.pause() - if (file !== undefined) { - file.end() - await fileClosed - } + await closeOutput() } if (fileError !== undefined) throw outputFailed(flags.output, fileError) } From 3546cf798f33419f30fc9dc258399688eb64ef26 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 12:27:42 +0300 Subject: [PATCH 12/34] fix(calls): guard settings changes by revision and leave cleanly from media - calls settings set: add --expected-revision; a stale revision exits 4 (state_conflict). get/set print the settings revision. - calls media --place: finish when the call ended before placement returned. - calls media: on Ctrl-C during an answer or join, wait for it to settle and then leave, so the platform does not keep this client as a participant. --- README.md | 9 ++- docs/command-research/calls.md | 2 + src/commands/calls/settings/get.ts | 2 +- src/commands/calls/settings/set.ts | 11 ++- src/services/calls.ts | 79 ++++++++++++++------- test/commands/calls-sockets.test.ts | 105 ++++++++++++++++++++++++++++ test/commands/calls.test.ts | 62 +++++++++++++++- test/helpers/calls-server.ts | 6 +- 8 files changed, 246 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 0619aa2d..c0a32d70 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,8 @@ polymorfa calls media --session support --place +15550100 \ polymorfa calls settings get --session support polymorfa calls settings set --session support --include-self-audio=true +polymorfa calls settings set --session support --calls-enabled=false \ + --expected-revision 3 polymorfa calls settings set --session support \ --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 ``` @@ -500,7 +502,12 @@ never answers or declines on its own. `--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a SIP trunk; `--sip-claim` controls whether the trunk's answer claims the call. `calls settings set` sends only the flags you pass; the other - settings keep their values. + settings keep their values. `calls settings get` and `set` print the + settings `revision`, which increases on every change and is `0` while the + session uses the defaults. Pass `--expected-revision ` to apply the + change only if the settings still have that revision; if someone changed + them first, the command exits with code 4 (`state_conflict`) and changes + nothing. `calls watch` streams inbound calls and their progress: `incoming`, `claim`, `state`, `participant_joined`, `participant_state`, `participant_left`, and diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index be0c3c8f..fc0bba7c 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -78,6 +78,8 @@ internal SDK module. - `calls settings` resolves Platform credentials, so client tokens fail with the standard Platform credential error. The SDK exposes the operation on the Messaging client; the CLI builds that client from the Platform environment. + `calls settings set --expected-revision` sends `expectedRevision`; a stale + revision returns 409 `state_conflict`, which exits with code 4. - Sockets carry the credential in their first frame. The CLI requires an HTTPS base URL, or HTTP on a loopback host, before opening any Calls connection. diff --git a/src/commands/calls/settings/get.ts b/src/commands/calls/settings/get.ts index 9a961b4a..da6e663c 100644 --- a/src/commands/calls/settings/get.ts +++ b/src/commands/calls/settings/get.ts @@ -6,7 +6,7 @@ import {renderCallSettings, viewCallSettings} from '../../../services/calls.js' export default class CallsSettingsGet extends BaseCommand { static override description = - 'Read a session’s call settings from the Platform API. Sessions without saved settings return the defaults. Requires a server API key.' + 'Read a session’s call settings from the Platform API. Sessions without saved settings return the defaults at revision 0. Pass the revision to `calls settings set --expected-revision` so a change cannot overwrite another one. Requires a server API key.' static override examples = [ '<%= config.bin %> calls settings get --session support', diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts index 057da6aa..55947388 100644 --- a/src/commands/calls/settings/set.ts +++ b/src/commands/calls/settings/set.ts @@ -9,11 +9,12 @@ import { export default class CallsSettingsSet extends BaseCommand { static override description = - 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' + 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --expected-revision applies the change only if the settings still have that revision (see `calls settings get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' static override examples = [ '<%= config.bin %> calls settings set --session support --calls-enabled=false', '<%= config.bin %> calls settings set --session support --include-self-audio=true', + '<%= config.bin %> calls settings set --session support --calls-enabled=false --expected-revision 3', '<%= config.bin %> calls settings set --session support --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', '<%= config.bin %> calls settings set --session support --inbound-route clients', ] @@ -23,6 +24,11 @@ export default class CallsSettingsSet extends BaseCommand { description: 'Allow calls on the session (false turns calling off).', options: ['true', 'false'], }), + 'expected-revision': Flags.integer({ + description: + 'Apply the change only if the settings still have this revision.', + min: 0, + }), 'include-self-audio': Flags.string({ description: 'Include each connection’s own audio in the merged audio it receives.', @@ -55,6 +61,9 @@ export default class CallsSettingsSet extends BaseCommand { ...(flags['calls-enabled'] === undefined ? {} : {callsEnabled: flags['calls-enabled'] === 'true'}), + ...(flags['expected-revision'] === undefined + ? {} + : {expectedRevision: flags['expected-revision']}), ...(flags['include-self-audio'] === undefined ? {} : {includeSelfAudio: flags['include-self-audio'] === 'true'}), diff --git a/src/services/calls.ts b/src/services/calls.ts index f14561be..77be414a 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -253,6 +253,8 @@ export async function viewCallSettings( export interface CallSettingsChanges { readonly callsEnabled?: boolean + /** Apply only if the settings still have this revision. */ + readonly expectedRevision?: number readonly includeSelfAudio?: boolean readonly inboundRoute?: CallInboundRoute readonly sipClaim?: boolean @@ -269,7 +271,8 @@ export async function updateCallSettings( changes: CallSettingsChanges, ): Promise> { const name = validateSession(session) - if (Object.values(changes).every((value) => value === undefined)) { + const {expectedRevision, ...settings} = changes + if (Object.values(settings).every((value) => value === undefined)) { throw new PolymorfaConfigurationError( 'Provide at least one setting to change.', 'calls-enabled', @@ -297,6 +300,7 @@ export async function updateCallSettings( ? {} : {sipTrunkId: changes.sipTrunkId}), ...(changes.sipClaim === undefined ? {} : {sipClaim: changes.sipClaim}), + ...(expectedRevision === undefined ? {} : {expectedRevision}), }) return result(response.data.data, response.metadata) } @@ -394,6 +398,7 @@ export function renderCallSettings( `SIP answer claims call\t${yesNo(settings.sipClaim)}`, ] : []), + `Revision\t${settings.revision ?? 0}`, `Updated\t${settings.updatedAt ?? 'never (defaults)'}`, ].join('\n') } @@ -819,8 +824,18 @@ export async function bridgeCallMedia( else resolveDone(summary(outcome)) } let lastCallError: CallsError | undefined + let stopping = false + /** Settles once an answer or join in flight has finished, either way. */ + let pendingAccept: Promise | undefined /** Stop our part: end a call still ringing that we placed, else leave. */ const hangUp = (call: Call): void => { + // Leaving while the answer is in flight only ends the call locally, and + // the platform would keep this client as a participant (and claimant). + // Wait for the answer to settle so the leave reaches the platform. + if (pendingAccept !== undefined) { + void pendingAccept.then(() => hangUp(call)) + return + } const stop = call.direction === 'outbound' && call.state === 'ringing' ? call.end() @@ -831,6 +846,29 @@ export async function bridgeCallMedia( ) } + const ended = (call: Call, reason: CallEndReason): void => { + status({ + type: 'ended', + callId: call.id, + reason, + duration: call.duration, + }) + if (reason === 'claimed') { + finish(callsClientError(new CallClaimedError())) + } else if (FAILED_ENDINGS.has(reason)) { + finish( + lastCallError === undefined + ? new PolymorfaConnectionError( + `The call media connection failed (${reason}).`, + {code: reason}, + ) + : callsClientError(lastCallError), + ) + } else { + finish(reason) + } + } + const attach = (call: Call): void => { active = call followCall(call, status) @@ -840,28 +878,7 @@ export async function bridgeCallMedia( status({type: 'notice', code: error.code, message: error.message}) } }) - call.once('ended', (reason) => { - status({ - type: 'ended', - callId: call.id, - reason, - duration: call.duration, - }) - if (reason === 'claimed') { - finish(callsClientError(new CallClaimedError())) - } else if (FAILED_ENDINGS.has(reason)) { - finish( - lastCallError === undefined - ? new PolymorfaConnectionError( - `The call media connection failed (${reason}).`, - {code: reason}, - ) - : callsClientError(lastCallError), - ) - } else { - finish(reason) - } - }) + call.once('ended', (reason) => ended(call, reason)) call.audio.on('data', (pcm) => { const bytes = new Uint8Array(pcm.length * 2) const view = new DataView(bytes.buffer) @@ -914,7 +931,17 @@ export async function bridgeCallMedia( exclusive: input.exclusive === true, ...(input.video === undefined ? {} : {video: input.video}), }) - accepting.catch((error: unknown) => finish(callsClientError(error))) + const settled = accepting.then( + () => undefined, + (error: unknown) => { + // A refusal after Ctrl-C is not an error: the user asked to stop. + if (!stopping) finish(callsClientError(error)) + }, + ) + const pending = settled.finally(() => { + if (pendingAccept === pending) pendingAccept = undefined + }) + pendingAccept = pending } client.on('ready', () => { @@ -953,6 +980,7 @@ export async function bridgeCallMedia( } const onAbort = (): void => { + stopping = true if (active === undefined || active.ended) finish('cancelled') else hangUp(active) } @@ -970,6 +998,9 @@ export async function bridgeCallMedia( }) status({type: 'placed', callId: call.id}) attach(call) + // Events that arrived before place() returned are already applied, + // so the call may have ended without emitting to our listener. + if (call.ended) ended(call, call.endReason ?? 'unknown') } catch (error) { finish(callsClientError(error)) } diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index aee8350b..fc05f442 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -370,6 +370,111 @@ describe('calls watch and media', () => { }) }) + test('finishes when a placed call ends before placement returns', async () => { + let lifecycle: FakeSocket | undefined + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + lifecycle = socket + socket.sendJson({participant: 'server:default', type: 'ready'}) + }, + rest: async () => { + if (lifecycle !== undefined) { + sendEvent(lifecycle, 'call.ended', 'call_8', {reason: 'rejected'}) + } + await delay(100) + return { + body: { + data: {callId: 'call_8', session: 'support', video: false}, + success: true, + }, + status: 201, + } + }, + }) + + const summary = await bridgeCallMedia( + { + baseUrl: server.baseUrl, + credential: {type: 'apiKey', value: API_KEY}, + output: () => undefined, + place: '+15550100', + session: 'support', + signal: AbortSignal.timeout(2_000), + }, + () => undefined, + ) + + expect(summary).toMatchObject({callId: 'call_8', reason: 'rejected'}) + expect( + server.sockets.filter((s) => !s.path.startsWith('/voip/ws')), + ).toHaveLength(0) + }) + + test('waits for an answer in flight before leaving on interrupt', async () => { + const controller = new AbortController() + let leaveFrame = false + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + if (socket.path.startsWith('/voip/ws')) { + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_5', {from: '+15550100'}) + return + } + socket.sendJson({sampleRate: 16000, type: 'ready', video: false}) + for (;;) { + const message = await socket.next() + if ( + typeof message === 'string' && + (JSON.parse(message) as {type?: string}).type === 'leave' + ) { + leaveFrame = true + return + } + } + }, + rest: async (request) => { + if (request.url.endsWith('/accept')) { + // Ctrl-C arrives while the answer is still in flight. + controller.abort() + await delay(100) + } + return { + body: { + data: { + answered: true, + answeredBy: 'server:default', + exclusive: true, + }, + success: true, + }, + } + }, + }) + + const summary = await bridgeCallMedia( + { + baseUrl: server.baseUrl, + credential: {type: 'apiKey', value: API_KEY}, + exclusive: true, + output: () => undefined, + session: 'support', + signal: controller.signal, + }, + () => undefined, + ) + + expect(summary).toMatchObject({callId: 'call_5', reason: 'left'}) + await delay(50) + const left = + leaveFrame || + server.requests.some( + ({method, url}) => method === 'POST' && url.endsWith('/leave'), + ) + expect(left).toBe(true) + }) + test('answers only the requested call and exits on a claim conflict', async () => { const outputPath = join(directory, 'out.pcm') server = await startCallsServer({ diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 10f63384..16c0df2a 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -329,7 +329,7 @@ describe('unified calls commands', () => { expect(viewed.error).toBeUndefined() expect(viewed.stdout).toBe( - 'Session\tsupport/main\nCalls\ton\nInclude self audio\tyes\nIncoming calls\tclients\nUpdated\t2026-09-16T10:00:00.000Z\n', + 'Session\tsupport/main\nCalls\ton\nInclude self audio\tyes\nIncoming calls\tclients\nRevision\t0\nUpdated\t2026-09-16T10:00:00.000Z\n', ) expect(updated.error).toBeUndefined() expect(jsonData(updated.stdout)).toEqual({ @@ -409,6 +409,66 @@ describe('unified calls commands', () => { expect(exitCode(empty.error)).toBe(2) }) + test('guards a change with the expected revision and exits 4 when stale', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + callsEnabled: false, + includeSelfAudio: false, + inboundRoute: 'clients', + revision: 4, + sipClaim: true, + sipTrunkId: null, + updatedAt: '2026-09-17T10:00:00.000Z', + }, + success: true, + }, + }, + apiError( + 409, + 'state_conflict', + 'The call settings changed since revision 3.', + 'req_stale', + ), + ]) + + const guarded = await apiKey(() => + runCommand( + 'calls settings set --session support --calls-enabled=false --expected-revision 3', + ), + ) + const stale = await apiKey(() => + runCommand( + 'calls settings set --session support --calls-enabled=true --expected-revision 3 --json', + ), + ) + const negative = await apiKey(() => + runCommand( + 'calls settings set --session support --calls-enabled=true --expected-revision -1', + ), + ) + const alone = await apiKey(() => + runCommand('calls settings set --session support --expected-revision 3'), + ) + + expect(guarded.error).toBeUndefined() + expect(guarded.stdout).toContain('Calls\toff') + expect(guarded.stdout).toContain('Revision\t4') + expect(exitCode(stale.error)).toBe(4) + expect(JSON.parse(stale.stderr.trim())).toMatchObject({ + error: {category: 'conflict', code: 'state_conflict'}, + }) + expect(exitCode(negative.error)).toBe(2) + expect(exitCode(alone.error)).toBe(2) + expect( + server.requests.map(({body}) => JSON.parse(body) as unknown), + ).toEqual([ + {callsEnabled: false, expectedRevision: 3}, + {callsEnabled: true, expectedRevision: 3}, + ]) + }) + test('turns calling off and reports calls_disabled refusals', async () => { server = await startHttpSequenceServer([ { diff --git a/test/helpers/calls-server.ts b/test/helpers/calls-server.ts index b0f62be2..1181927a 100644 --- a/test/helpers/calls-server.ts +++ b/test/helpers/calls-server.ts @@ -29,7 +29,9 @@ export interface CallsServer { export interface CallsServerOptions { readonly onSocket?: (socket: FakeSocket) => void | Promise - readonly rest?: (request: CapturedRequest) => ResponseFixture + readonly rest?: ( + request: CapturedRequest, + ) => ResponseFixture | Promise } /** @@ -55,7 +57,7 @@ export async function startCallsServer( url: request.url ?? '', } requests.push(captured) - const response = options.rest?.(captured) ?? { + const response = (await options.rest?.(captured)) ?? { body: {error: 'No REST fixture.'}, status: 500, } From 6ef9ef9a6f2142f1d42e578c7aafe733e4122e3d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 12:44:53 +0300 Subject: [PATCH 13/34] chore(deps): pin SDK b62cc8d --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac57181e..1a174271 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#01cf404b7eac4492648e867099328b3f7d8bc376", + "@polymorfa/sdk": "github:polymorfa/sdks#b62cc8d2c12fc0b5d9996adb9dbf5d8ecf3451cb", "zod": "4.2.0" }, "devDependencies": { From 05449b5f2168791f3eefa77f6931b00dcdcc55f9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 12:53:34 +0300 Subject: [PATCH 14/34] fix(calls): protect and preflight the calls media input file - Refuse --input and --output that name the same file (by resolved path or inode) before opening either, so the input is not truncated. - Open --input before connecting and report an unreadable file as a configuration error; a later read error stops the call and exits 2. --- README.md | 6 +- src/commands/calls/media.ts | 69 ++++++++++++++++++--- test/commands/calls-sockets.test.ts | 96 ++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c0a32d70..353be82a 100644 --- a/README.md +++ b/README.md @@ -529,8 +529,10 @@ time, `--output ` receives audio (standard output by default, never a terminal), and `--leave-on-eof` leaves after the input is sent. Status goes to standard error, as JSON lines with `--json`. Ctrl-C leaves the call, or ends a placed call that is still ringing. If the `--output` file cannot be written, -the command leaves the call and exits with code 2. The command carries audio -only. +or the `--input` file cannot be read, the command leaves the call and exits +with code 2. It exits with code 2 before connecting when the `--input` file +cannot be opened or when `--input` and `--output` name the same file. The +command carries audio only. Convert audio with any tool that writes raw PCM, for example: diff --git a/src/commands/calls/media.ts b/src/commands/calls/media.ts index e75c5dac..f3706905 100644 --- a/src/commands/calls/media.ts +++ b/src/commands/calls/media.ts @@ -1,5 +1,7 @@ -import {createReadStream, createWriteStream, type WriteStream} from 'node:fs' +import {createWriteStream, type ReadStream, type WriteStream} from 'node:fs' +import {open, stat} from 'node:fs/promises' import {once} from 'node:events' +import {resolve} from 'node:path' import {Args, Flags} from '@oclif/core' import {PolymorfaConfigurationError} from '@polymorfa/sdk' @@ -84,7 +86,32 @@ export default class CallsMedia extends BaseCommand { 'input', ) } + const inputPath = + flags.input === undefined || flags.input === '-' ? undefined : flags.input + const outputPath = flags.output === '-' ? undefined : flags.output + // Opening --output truncates it, so the same file would lose its audio + // before a byte of it was read. + if ( + inputPath !== undefined && + outputPath !== undefined && + (await sameFile(inputPath, outputPath)) + ) { + throw new PolymorfaConfigurationError( + '--input and --output name the same file. Write received audio to a different file.', + 'output', + ) + } const environment = await this.messagingEnvironment(flags) + // Open --input before connecting, so a missing or unreadable file fails + // here rather than as an unhandled stream error. + let inputFile: ReadStream | undefined + if (inputPath !== undefined) { + try { + inputFile = (await open(inputPath, 'r')).createReadStream() + } catch (error) { + throw inputFailed(inputPath, error) + } + } const json = this.outputMode(flags) === 'json' const quiet = this.outputMode(flags) === 'quiet' const report = (value: object, human: string): void => { @@ -98,7 +125,7 @@ export default class CallsMedia extends BaseCommand { const onStop = (): void => halt.abort() stop.signal.addEventListener('abort', onStop, {once: true}) const file: WriteStream | undefined = - flags.output === '-' ? undefined : createWriteStream(flags.output) + outputPath === undefined ? undefined : createWriteStream(outputPath) // Listen before the stream can fail or close: its errors are asynchronous. // `once` rejects on the stream's error, which is recorded below. const fileClosed = @@ -114,12 +141,13 @@ export default class CallsMedia extends BaseCommand { if (file === undefined) process.stdout.write(pcm) else file.write(pcm) } - const input = - flags.input === undefined - ? undefined - : flags.input === '-' - ? process.stdin - : createReadStream(flags.input) + // A read error can arrive before the call connects and reading starts. + let inputError: unknown + inputFile?.on('error', (error: Error) => { + inputError ??= error + halt.abort() + }) + const input = flags.input === '-' ? process.stdin : inputFile // Flush and close the output file once; its errors arrive asynchronously. let outputClosed = false @@ -150,6 +178,9 @@ export default class CallsMedia extends BaseCommand { ) await closeOutput() if (fileError !== undefined) throw outputFailed(flags.output, fileError) + if (inputError !== undefined && inputPath !== undefined) { + throw inputFailed(inputPath, inputError) + } report( {type: 'finished', ...summary}, `Finished (${summary.reason}): received ${summary.bytesReceived} bytes, sent ${summary.bytesSent} bytes.`, @@ -186,6 +217,28 @@ function renderMediaStatus( } } +/** Whether two paths name one file, through links or relative spellings. */ +async function sameFile(first: string, second: string): Promise { + if (resolve(first) === resolve(second)) return true + const [a, b] = await Promise.all([ + stat(first).catch(() => undefined), + stat(second).catch(() => undefined), + ]) + return ( + a !== undefined && b !== undefined && a.dev === b.dev && a.ino === b.ino + ) +} + +function inputFailed( + path: string, + error: unknown, +): PolymorfaConfigurationError { + return new PolymorfaConfigurationError( + `Could not read audio from ${path}: ${error instanceof Error ? error.message : String(error)}`, + 'input', + ) +} + function outputFailed(path: string, error: Error): PolymorfaConfigurationError { return new PolymorfaConfigurationError( `Could not write received audio to ${path}: ${error.message}`, diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index fc05f442..1fd2b073 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -1,4 +1,12 @@ -import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import { + link, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' import {Readable} from 'node:stream' @@ -591,6 +599,92 @@ describe('calls watch and media', () => { expect(result.stderr).not.toContain('"finished"') }) + test('refuses --input and --output naming the same file before opening either', async () => { + const inputPath = join(directory, 'in.pcm') + const input = pcm(160) + await writeFile(inputPath, input) + await symlink(inputPath, join(directory, 'link.pcm')) + await link(inputPath, join(directory, 'hard.pcm')) + server = await startCallsServer({}) + + for (const output of [ + inputPath, + join(directory, '.', 'in.pcm'), + join(directory, 'link.pcm'), + join(directory, 'hard.pcm'), + ]) { + const result = await apiKey(() => + runCommand( + `calls media --session support --input ${inputPath} --output ${output} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('name the same file') + } + expect(await readFile(inputPath)).toEqual(input) + expect(server.sockets).toHaveLength(0) + }) + + test('refuses a missing --input file before connecting', async () => { + server = await startCallsServer({}) + + const result = await apiKey(() => + runCommand( + `calls media --session support --input ${join(directory, 'missing.pcm')} --output ${join(directory, 'out.pcm')} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(JSON.parse(result.stderr.trim().split('\n').at(-1)!)).toMatchObject({ + error: {category: 'configuration'}, + }) + expect(result.stderr).toContain('Could not read audio from') + expect(server.sockets).toHaveLength(0) + }) + + test('stops with a configuration error when --input cannot be read', async () => { + // A directory opens for reading but fails on the first read. + const unreadable = join(directory, 'folder') + await mkdir(unreadable) + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + if (socket.path.startsWith('/voip/ws')) { + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_6', {from: '+15550100'}) + return + } + socket.sendJson({sampleRate: 16000, type: 'ready', video: false}) + for (;;) { + if (typeof (await socket.next()) === 'string') return + } + }, + rest: () => ({ + body: { + data: { + answered: true, + answeredBy: 'server:default', + exclusive: false, + }, + success: true, + }, + }), + }) + + const result = await apiKey(() => + runCommand( + `calls media call_6 --session support --input ${unreadable} --output ${join(directory, 'out.pcm')} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(JSON.parse(result.stderr.trim().split('\n').at(-1)!)).toMatchObject({ + error: {category: 'configuration'}, + }) + expect(result.stderr).toContain(`Could not read audio from ${unreadable}`) + }) + test('paces input audio in real time and leaves when interrupted', async () => { const controller = new AbortController() let received = 0 From 3b4cd9be29e9d3151662edf55788f08ae6290cdf Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 13:08:40 +0300 Subject: [PATCH 15/34] feat(calls): set Cloud API call hosting from calls settings calls settings set adds --host-cloud-api-calls, and settings output shows who answers a Cloud API session's calls. Pin SDK 7391cb0. --- README.md | 4 +++- package.json | 2 +- src/commands/calls/settings/set.ts | 10 +++++++++- src/services/calls.ts | 5 +++++ test/commands/calls.test.ts | 14 ++++++++++++-- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 353be82a..645db9d3 100644 --- a/README.md +++ b/README.md @@ -501,7 +501,9 @@ never answers or declines on its own. connection receives includes its own audio. It is off by default. `--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a SIP trunk; `--sip-claim` controls whether the trunk's answer claims the - call. `calls settings set` sends only the flags you pass; the other + call. On a Cloud API session, `--host-cloud-api-calls=true` has Polymorfa + Calls answer incoming calls; with the default `false`, your Graph API + integration answers them. `calls settings set` sends only the flags you pass; the other settings keep their values. `calls settings get` and `set` print the settings `revision`, which increases on every change and is `0` while the session uses the defaults. Pass `--expected-revision ` to apply the diff --git a/package.json b/package.json index 1a174271..f43cabb5 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#b62cc8d2c12fc0b5d9996adb9dbf5d8ecf3451cb", + "@polymorfa/sdk": "github:polymorfa/sdks#7391cb0c4b9099c076cf01dc13c982a7ff7bf961", "zod": "4.2.0" }, "devDependencies": { diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts index 55947388..c1780f37 100644 --- a/src/commands/calls/settings/set.ts +++ b/src/commands/calls/settings/set.ts @@ -9,7 +9,7 @@ import { export default class CallsSettingsSet extends BaseCommand { static override description = - 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --expected-revision applies the change only if the settings still have that revision (see `calls settings get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. Requires a server API key.' + 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --expected-revision applies the change only if the settings still have that revision (see `calls settings get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. --host-cloud-api-calls=true has Polymorfa Calls answer a Cloud API session’s incoming calls instead of your Graph API integration. Requires a server API key.' static override examples = [ '<%= config.bin %> calls settings set --session support --calls-enabled=false', @@ -29,6 +29,11 @@ export default class CallsSettingsSet extends BaseCommand { 'Apply the change only if the settings still have this revision.', min: 0, }), + 'host-cloud-api-calls': Flags.string({ + description: + 'On a Cloud API session, answer incoming calls with Polymorfa Calls (false leaves them to your Graph API integration).', + options: ['true', 'false'], + }), 'include-self-audio': Flags.string({ description: 'Include each connection’s own audio in the merged audio it receives.', @@ -64,6 +69,9 @@ export default class CallsSettingsSet extends BaseCommand { ...(flags['expected-revision'] === undefined ? {} : {expectedRevision: flags['expected-revision']}), + ...(flags['host-cloud-api-calls'] === undefined + ? {} + : {hostCloudApiCalls: flags['host-cloud-api-calls'] === 'true'}), ...(flags['include-self-audio'] === undefined ? {} : {includeSelfAudio: flags['include-self-audio'] === 'true'}), diff --git a/src/services/calls.ts b/src/services/calls.ts index 77be414a..a2b21d46 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -255,6 +255,7 @@ export interface CallSettingsChanges { readonly callsEnabled?: boolean /** Apply only if the settings still have this revision. */ readonly expectedRevision?: number + readonly hostCloudApiCalls?: boolean readonly includeSelfAudio?: boolean readonly inboundRoute?: CallInboundRoute readonly sipClaim?: boolean @@ -300,6 +301,9 @@ export async function updateCallSettings( ? {} : {sipTrunkId: changes.sipTrunkId}), ...(changes.sipClaim === undefined ? {} : {sipClaim: changes.sipClaim}), + ...(changes.hostCloudApiCalls === undefined + ? {} + : {hostCloudApiCalls: changes.hostCloudApiCalls}), ...(expectedRevision === undefined ? {} : {expectedRevision}), }) return result(response.data.data, response.metadata) @@ -398,6 +402,7 @@ export function renderCallSettings( `SIP answer claims call\t${yesNo(settings.sipClaim)}`, ] : []), + `Cloud API calls answered by\t${settings.hostCloudApiCalls === true ? 'Polymorfa Calls' : 'your Graph API integration'}`, `Revision\t${settings.revision ?? 0}`, `Updated\t${settings.updatedAt ?? 'never (defaults)'}`, ].join('\n') diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 16c0df2a..08904720 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -329,7 +329,7 @@ describe('unified calls commands', () => { expect(viewed.error).toBeUndefined() expect(viewed.stdout).toBe( - 'Session\tsupport/main\nCalls\ton\nInclude self audio\tyes\nIncoming calls\tclients\nRevision\t0\nUpdated\t2026-09-16T10:00:00.000Z\n', + 'Session\tsupport/main\nCalls\ton\nInclude self audio\tyes\nIncoming calls\tclients\nCloud API calls answered by\tyour Graph API integration\nRevision\t0\nUpdated\t2026-09-16T10:00:00.000Z\n', ) expect(updated.error).toBeUndefined() expect(jsonData(updated.stdout)).toEqual({ @@ -376,6 +376,11 @@ describe('unified calls commands', () => { 'calls settings set --session support --inbound-route sip_trunk', ), ) + const hosting = await apiKey(() => + runCommand( + 'calls settings set --session support --host-cloud-api-calls=true', + ), + ) const conflicting = await apiKey(() => runCommand( `calls settings set --session support --inbound-route clients --sip-trunk ${TRUNK_ID}`, @@ -385,17 +390,21 @@ describe('unified calls commands', () => { runCommand('calls settings set --session support'), ) - for (const ok of [selfAudio, trunk, back, keepTrunk]) { + for (const ok of [selfAudio, trunk, back, keepTrunk, hosting]) { expect(ok.error).toBeUndefined() } expect(selfAudio.stdout).toContain('Incoming calls\tclients and SIP trunk') expect(selfAudio.stdout).toContain(`SIP trunk\t${TRUNK_ID}`) expect(selfAudio.stdout).toContain('SIP answer claims call\tno') + expect(selfAudio.stdout).toContain( + 'Cloud API calls answered by\tyour Graph API integration', + ) expect(server.requests.map(({method}) => method)).toEqual([ 'PUT', 'PUT', 'PUT', 'PUT', + 'PUT', ]) expect( server.requests.map(({body}) => JSON.parse(body) as unknown), @@ -404,6 +413,7 @@ describe('unified calls commands', () => { {inboundRoute: 'sip_trunk', sipTrunkId: TRUNK_ID}, {inboundRoute: 'clients', sipClaim: false}, {inboundRoute: 'sip_trunk'}, + {hostCloudApiCalls: true}, ]) expect(exitCode(conflicting.error)).toBe(2) expect(exitCode(empty.error)).toBe(2) From 0a34d6ab868506dc76d5d2f5071336057902e763 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 13:25:22 +0300 Subject: [PATCH 16/34] fix(calls): end calls placed after Ctrl-C and release standard input - calls media --place: when Ctrl-C arrives while placement is in flight, end the placed call instead of attaching it, so the callee stops ringing. - calls media --input -: destroy standard input when the bridge finishes, so a producer that never closes it does not keep the process running. --- src/commands/calls/media.ts | 4 +- src/services/calls.ts | 5 ++ test/commands/calls-sockets.test.ts | 125 ++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/commands/calls/media.ts b/src/commands/calls/media.ts index f3706905..65650e75 100644 --- a/src/commands/calls/media.ts +++ b/src/commands/calls/media.ts @@ -189,7 +189,9 @@ export default class CallsMedia extends BaseCommand { stop.signal.removeEventListener('abort', onStop) stop.dispose() if (input !== undefined && input !== process.stdin) input.destroy() - if (input === process.stdin) process.stdin.pause() + // Reading standard input stays blocked on a producer that never closes + // it (a microphone, tail -f), which would keep the process alive. + if (input === process.stdin) process.stdin.destroy() await closeOutput() } if (fileError !== undefined) throw outputFailed(flags.output, fileError) diff --git a/src/services/calls.ts b/src/services/calls.ts index a2b21d46..af33d9fe 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -1001,6 +1001,11 @@ export async function bridgeCallMedia( exclusive: input.exclusive === true, ...(input.video === undefined ? {} : {video: input.video}), }) + if (finished) { + // Cancelled while placing: stop the callee's phone ringing. + await call.end().catch(() => undefined) + return await done + } status({type: 'placed', callId: call.id}) attach(call) // Events that arrived before place() returned are already applied, diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index 1fd2b073..eb620714 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -9,7 +9,10 @@ import { } from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' +import {spawn} from 'node:child_process' +import {once} from 'node:events' import {Readable} from 'node:stream' +import {fileURLToPath} from 'node:url' import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, test} from 'vitest' @@ -419,6 +422,128 @@ describe('calls watch and media', () => { ).toHaveLength(0) }) + test('ends a call whose placement returns after an interrupt', async () => { + const controller = new AbortController() + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({participant: 'server:default', type: 'ready'}) + }, + rest: async (request) => { + if (request.method === 'POST') { + // Ctrl-C arrives while the placement is still in flight. + controller.abort() + await delay(100) + return { + body: { + data: {callId: 'call_7', session: 'support', video: false}, + success: true, + }, + status: 201, + } + } + return {body: {data: null, success: true}} + }, + }) + const statuses: string[] = [] + + const summary = await bridgeCallMedia( + { + baseUrl: server.baseUrl, + credential: {type: 'apiKey', value: API_KEY}, + output: () => undefined, + place: '+15550100', + session: 'support', + signal: controller.signal, + }, + (status) => statuses.push(status.type), + ) + + expect(summary.reason).toBe('cancelled') + expect(statuses).not.toContain('placed') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /messaging/voip/calls', + 'DELETE /messaging/voip/calls/call_7', + ]) + }) + + test('exits after the call ends while standard input stays open', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + if (socket.path.startsWith('/voip/ws')) { + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_2', {from: '+15550100'}) + await delay(300) + sendEvent(socket, 'call.ended', 'call_2', {reason: 'remote_hangup'}) + return + } + socket.sendJson({sampleRate: 16000, type: 'ready', video: false}) + for (;;) await socket.next() + }, + rest: () => ({ + body: { + data: { + answered: true, + answeredBy: 'server:default', + exclusive: false, + }, + success: true, + }, + }), + }) + + // A producer that never closes standard input, like a microphone. + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../../bin/dev.js', import.meta.url)), + 'calls', + 'media', + 'call_2', + '--session', + 'support', + '--input', + '-', + '--output', + join(directory, 'out.pcm'), + '--json', + ], + { + env: { + ...process.env, + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: '', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += String(chunk) + }) + child.stdin.on('error', () => undefined) + child.stdin.write(pcm(160)) + let timer: NodeJS.Timeout | undefined + try { + const exited = once(child, 'exit') as Promise<[number | null]> + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), 15_000) + }) + const outcome = await Promise.race([exited, timeout]) + + expect(outcome, stderr).not.toBe('timeout') + expect(outcome).toEqual([0, null]) + expect(stderr).toContain('"reason":"remote_hangup"') + } finally { + clearTimeout(timer) + child.kill() + } + }, 30_000) + test('waits for an answer in flight before leaving on interrupt', async () => { const controller = new AbortController() let leaveFrame = false From 43699411473aeeaf3f138034527823caf2c9b657 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 13:30:53 +0300 Subject: [PATCH 17/34] chore(deps): pin the SDK to 65bd14f --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f43cabb5..84dc686a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#7391cb0c4b9099c076cf01dc13c982a7ff7bf961", + "@polymorfa/sdk": "github:polymorfa/sdks#65bd14f9ed95b47159818b5126c5d4b288d1fcd3", "zod": "4.2.0" }, "devDependencies": { From 2cabb838a7fe67cb4ded976948bdea265f320458 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 15:17:21 +0300 Subject: [PATCH 18/34] chore(deps): pin the SDK to 7c0dc9b --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 84dc686a..7c470b7b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#65bd14f9ed95b47159818b5126c5d4b288d1fcd3", + "@polymorfa/sdk": "github:polymorfa/sdks#7c0dc9b1874626b8dcf7a6e6f6b33659640c0893", "zod": "4.2.0" }, "devDependencies": { From 85a2756e0d849d413f02467da7f6a5fa6aea7937 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 16:37:06 +0300 Subject: [PATCH 19/34] chore(deps): pin the SDK to 81c4e6e --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c470b7b..9e235063 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#7c0dc9b1874626b8dcf7a6e6f6b33659640c0893", + "@polymorfa/sdk": "github:polymorfa/sdks#81c4e6e5ff97cb19a6bd506d7af230ce251e9313", "zod": "4.2.0" }, "devDependencies": { From bc958ac4068263fe53cf42c926c794588a4a09d9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 17:08:27 +0300 Subject: [PATCH 20/34] chore(deps): pin the SDK to 3ddefd8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9e235063..14449542 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#81c4e6e5ff97cb19a6bd506d7af230ce251e9313", + "@polymorfa/sdk": "github:polymorfa/sdks#3ddefd8d3a39d3d55b47e8d128ab8bfe864c2ee5", "zod": "4.2.0" }, "devDependencies": { From b5a1e67f3c0832f030407c05126b5504be61f9be Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 17:38:32 +0300 Subject: [PATCH 21/34] chore(deps): pin the SDK to f8e107b --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 14449542..516acce6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#3ddefd8d3a39d3d55b47e8d128ab8bfe864c2ee5", + "@polymorfa/sdk": "github:polymorfa/sdks#f8e107bd0f6c5b02a48f79cb68e6fc941f32d56f", "zod": "4.2.0" }, "devDependencies": { From 660385b85e3718072471550e329e7c2aa1e71393 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 18:07:30 +0300 Subject: [PATCH 22/34] chore(deps): pin the SDK to 1cadd54 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 516acce6..a3e38eb0 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#f8e107bd0f6c5b02a48f79cb68e6fc941f32d56f", + "@polymorfa/sdk": "github:polymorfa/sdks#1cadd5424dd916005ecd13b7bf5403ebf93eeae0", "zod": "4.2.0" }, "devDependencies": { From 12c87a39c0c3eae720c16c3f2abeb39a794978aa Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 18:37:02 +0300 Subject: [PATCH 23/34] chore(deps): pin the SDK to 12cdbd4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a3e38eb0..54b93bab 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#1cadd5424dd916005ecd13b7bf5403ebf93eeae0", + "@polymorfa/sdk": "github:polymorfa/sdks#12cdbd42c91914abd1d732e832ddc05a1f75a24c", "zod": "4.2.0" }, "devDependencies": { From 5fbc61722a7df0514fac1d09c26aa68a000a7b40 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 19:08:16 +0300 Subject: [PATCH 24/34] chore(deps): pin the SDK to 109a95c --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 54b93bab..5ef87ed4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#12cdbd42c91914abd1d732e832ddc05a1f75a24c", + "@polymorfa/sdk": "github:polymorfa/sdks#109a95c4fdb7f5a7790336c8bbf1b29b5a333779", "zod": "4.2.0" }, "devDependencies": { From 09c7be2e9a4e7d506ea24a691a03bd3a2f74e031 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 19:38:57 +0300 Subject: [PATCH 25/34] chore(deps): pin the SDK to d85fd5c --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5ef87ed4..dd37e645 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#109a95c4fdb7f5a7790336c8bbf1b29b5a333779", + "@polymorfa/sdk": "github:polymorfa/sdks#d85fd5c25a4eb93d2e6340677ba1f3c55d4cf48d", "zod": "4.2.0" }, "devDependencies": { From 6802886e7f96cc105f87daa017310670f5a70328 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 20:08:54 +0300 Subject: [PATCH 26/34] chore(deps): pin the SDK to cb7bfdb --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd37e645..22a25cc1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#d85fd5c25a4eb93d2e6340677ba1f3c55d4cf48d", + "@polymorfa/sdk": "github:polymorfa/sdks#cb7bfdb7cfad20aa4474dc5d439a0a278541e8e5", "zod": "4.2.0" }, "devDependencies": { From 67820a1b4d7c82e018514a25d15832244a315102 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 20:40:42 +0300 Subject: [PATCH 27/34] chore(deps): pin the SDK to 4ba6271 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 22a25cc1..793e7b17 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#cb7bfdb7cfad20aa4474dc5d439a0a278541e8e5", + "@polymorfa/sdk": "github:polymorfa/sdks#4ba6271833f446e17c553c28e57a803b68298260", "zod": "4.2.0" }, "devDependencies": { From c6345bc2d981d7f7f4dcf28263a047bae20aee7d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 21:11:20 +0300 Subject: [PATCH 28/34] chore(deps): pin the SDK to 25b68cf --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 793e7b17..082dda23 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#4ba6271833f446e17c553c28e57a803b68298260", + "@polymorfa/sdk": "github:polymorfa/sdks#25b68cf1b272eb7cd8ddf05e3aef285611f3c1e0", "zod": "4.2.0" }, "devDependencies": { From dded4846d272aec4cb3b5922626ec8af3b2f931f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 18 Sep 2026 13:09:17 +0200 Subject: [PATCH 29/34] chore(deps): pin the SDK to fc6968c --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 082dda23..c9d6e1af 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#25b68cf1b272eb7cd8ddf05e3aef285611f3c1e0", + "@polymorfa/sdk": "github:polymorfa/sdks#fc6968c0a58e65c34d370b8ab4e4e8cabd9ece53", "zod": "4.2.0" }, "devDependencies": { From 733efa838bd38bb392044db8484aab53b69ee8a1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 18 Sep 2026 13:22:23 +0200 Subject: [PATCH 30/34] build(deps)!: take the Calls client from @polymorfa/sdk/calls and require Node 22 Pin @polymorfa/sdk to polymorfa/sdks@70b671e, which ships the Calls client as the @polymorfa/sdk/calls subpath, and drop the unpublished @polymorfa/calls dependency. Raise engines.node to >=22, drop the Node 20 CI job, and destroy the test server response when a REST fixture rejects. --- .github/workflows/ci.yml | 1 - README.md | 5 ++--- docs/command-research/calls.md | 16 ++++++++-------- docs/development/bansafe-sdk-integration.md | 3 ++- .../specs/2026-08-19-polymorfa-cli-design.md | 13 ++++++------- package-lock.json | 8 ++++---- package.json | 5 ++--- src/commands/mcp/doctor.ts | 2 +- src/services/calls.ts | 2 +- test/helpers/calls-server.ts | 10 +++++++++- 10 files changed, 35 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeefc0cd..3e494e1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: fail-fast: false matrix: node-version: - - 20 - 22 steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index 645db9d3..f8bd653d 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@ access until `@polymorfa/cli` is released publicly. ## Requirements -- Node.js 20 or newer; `calls watch` and `calls media` require Node.js 22 or - newer +- Node.js 22 or newer - A Polymorfa organization API key or exact-project token for developer requests - A server API key or short-lived client token for Messaging requests @@ -1013,7 +1012,7 @@ npm run verify The verification gate checks formatting, lint, strict TypeScript, tests, production build, npm package contents, and the production dependency audit on -Node.js 20 and 22 in hosted CI. +Node.js 22 in hosted CI. ### Guided session creation and configuration diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index fc0bba7c..19bd86c2 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -2,9 +2,9 @@ - **Status:** Implemented against Calls contract revision 1, unpublished - **Reviewed:** 2026-09-17 -- **SDK baseline:** `@polymorfa/sdk` and `@polymorfa/calls` at - `polymorfa/sdks` commit `a65291cf6de0574b655affdcab5e6fe44c517b53` - (not yet pushed) +- **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` + subpath) at `polymorfa/sdks` commit + `70b671eb5bf9a094ec3a5c8e51afa6f425251218` ## Sources reviewed @@ -17,7 +17,7 @@ `session` and `participant` query parameters, 60-second re-validation, and the 4401, 4409, 4429, 4400, and 1013 close codes. - The public guide for Calls. -- `MessagingClient.voip` and the public `@polymorfa/calls` entry point +- `MessagingClient.voip` and the public `@polymorfa/sdk/calls` entry point (`CallsClient`, `Call`, and the Calls error classes). ## Shipped command tree @@ -120,7 +120,7 @@ internal SDK module. client generates it; status output reports it. - **Runtime.** Socket commands need a global `WebSocket` (Node.js 22 or newer). On older runtimes they fail with a configuration error before - connecting. REST commands keep the Node.js 20 floor. + connecting. The CLI as a whole requires Node.js 22 or newer. ## Explicit gaps @@ -129,6 +129,6 @@ internal SDK module. - `calls media` does not attach to a call that was already ringing or answered before it started, and carries no video. - `calls watch` does not report outbound calls placed by other participants. -- The `@polymorfa/calls` dependency is not published and cannot be installed - from a Git subdirectory with npm. The CLI cannot release until both SDK - packages are published with compatible semver ranges. +- `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is not + published; development pins an exact Git commit. The CLI cannot release + until `@polymorfa/sdk` is published with a compatible semver range. diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index fe4075b2..559f0c0e 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -4,7 +4,8 @@ The BanSafe CLI commands depend on the matching typed SDK revision pinned in `package.json`. During coordinated SDK and CLI development, verify both worktrees without replacing that immutable dependency in either manifest: -The pinned SDK revision is `polymorfa/sdks@8803f0554314214519971cb3306e0c0efe21cb76`. +The pinned SDK revision is `polymorfa/sdks@70b671eb5bf9a094ec3a5c8e51afa6f425251218` +(the Calls client ships in the same package as `@polymorfa/sdk/calls`). Its coverage ledger is reconciled against the API contracts at `polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform snapshot SHA-256 diff --git a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md index f499caac..a545f7bd 100644 --- a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md +++ b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md @@ -143,13 +143,12 @@ hatch to paper over a missing public contract. The explicit `api` command is the user-directed low-level escape hatch and keeps the SDK's authentication, transport, retry, pagination, and error behavior. -The CLI depends on the root server package and on `@polymorfa/calls`, the -programmatic Calls client, for the call event stream and call audio. It uses -only the public, neutral entry point of `@polymorfa/calls`, never its internal +The CLI depends on the root server package and on its `@polymorfa/sdk/calls` +subpath, the programmatic Calls client, for the call event stream and call +audio. It uses only that public, neutral entry point, never the Calls internal module. Browser, React, Next.js, UI, Elements, and Devtools SDK workspaces are -not CLI runtime dependencies. npm cannot install a Git subdirectory, so -`@polymorfa/calls` resolves only after it is published; until then development -installs unpack the packed workspace locally. +not CLI runtime dependencies. The Calls client ships inside `@polymorfa/sdk`, +so the pinned Git commit installs it with no separate package. ## Internal boundaries @@ -1038,7 +1037,7 @@ maintenance work. The current implementation branch contains working, unpublished vertical slices: -- the installable `polymorfa` ESM executable on Node.js 20 or newer; +- the installable `polymorfa` ESM executable on Node.js 22 or newer; - `polymorfa version` with human, JSON, and quiet output; - `polymorfa api ` for Messaging or Platform credentials; - GET query fields, JSON request fields, custom headers, file or bounded stdin diff --git a/package-lock.json b/package-lock.json index c7b2c17d..37bdd14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#8803f0554314214519971cb3306e0c0efe21cb76", + "@polymorfa/sdk": "github:polymorfa/sdks#70b671eb5bf9a094ec3a5c8e51afa6f425251218", "zod": "4.2.0" }, "bin": { @@ -30,7 +30,7 @@ "vitest": "3.2.7" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@esbuild/aix-ppc64": { @@ -997,8 +997,8 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#8803f0554314214519971cb3306e0c0efe21cb76", - "integrity": "sha512-rLG5Gg7X3U3iBxHU93PVZGrIIUnNlnX7I4TT1k8EaYQrkjPm6TkA83akOEPQzJbzTMQa13+hUqT/P4deYSyh5Q==", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#70b671eb5bf9a094ec3a5c8e51afa6f425251218", + "integrity": "sha512-vUknb1Jc6MaLkYlNHQPVN1SDmRGsdb0tcgnO+ql+PPNrA7gnm5rimAeNGnQZDTmtme6e3z+RriRUQB89wC9mJA==", "workspaces": [ "packages/calls", "packages/browser", diff --git a/package.json b/package.json index c9d6e1af..9fb9f1aa 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22" }, "bin": { "polymorfa": "./bin/run.js" @@ -33,8 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/calls": "0.1.0-dev.0", - "@polymorfa/sdk": "github:polymorfa/sdks#fc6968c0a58e65c34d370b8ab4e4e8cabd9ece53", + "@polymorfa/sdk": "github:polymorfa/sdks#70b671eb5bf9a094ec3a5c8e51afa6f425251218", "zod": "4.2.0" }, "devDependencies": { diff --git a/src/commands/mcp/doctor.ts b/src/commands/mcp/doctor.ts index c2fc69d1..abdfde21 100644 --- a/src/commands/mcp/doctor.ts +++ b/src/commands/mcp/doctor.ts @@ -16,7 +16,7 @@ export default class McpDoctor extends BaseCommand { listenerProtocolSha256: LISTENER_PROTOCOL_SHA256, listenerProtocolVersion: LISTENER_PROTOCOL_VERSION, node: process.versions.node, - nodeSupported: major >= 20, + nodeSupported: major >= 22, tools: MCP_TOOL_NAMES.length, } this.writeValue( diff --git a/src/services/calls.ts b/src/services/calls.ts index af33d9fe..21713b49 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -10,7 +10,7 @@ import { type CallClaim, type CallEndReason, type CallState, -} from '@polymorfa/calls' +} from '@polymorfa/sdk/calls' import { PolymorfaAuthenticationError, PolymorfaAuthorizationError, diff --git a/test/helpers/calls-server.ts b/test/helpers/calls-server.ts index 1181927a..8ea7fa86 100644 --- a/test/helpers/calls-server.ts +++ b/test/helpers/calls-server.ts @@ -57,7 +57,15 @@ export async function startCallsServer( url: request.url ?? '', } requests.push(captured) - const response = (await options.rest?.(captured)) ?? { + let fixture: ResponseFixture | undefined + try { + fixture = await options.rest?.(captured) + } catch (error) { + // A rejecting fixture must not leave the client waiting on a reply. + reply.destroy(error instanceof Error ? error : undefined) + return + } + const response = fixture ?? { body: {error: 'No REST fixture.'}, status: 500, } From a1c62f222e5186838fd7fedc9bcbb0f7c91b0cfb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 18 Sep 2026 13:28:56 +0200 Subject: [PATCH 31/34] chore(deps): pin the SDK to 589618b Takes the Node 22 fix for Calls sockets whose handshake fails. --- docs/command-research/calls.md | 2 +- docs/development/bansafe-sdk-integration.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 19bd86c2..ca7541a6 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -4,7 +4,7 @@ - **Reviewed:** 2026-09-17 - **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` subpath) at `polymorfa/sdks` commit - `70b671eb5bf9a094ec3a5c8e51afa6f425251218` + `589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2` ## Sources reviewed diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index 559f0c0e..c3e4aef3 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -4,7 +4,7 @@ The BanSafe CLI commands depend on the matching typed SDK revision pinned in `package.json`. During coordinated SDK and CLI development, verify both worktrees without replacing that immutable dependency in either manifest: -The pinned SDK revision is `polymorfa/sdks@70b671eb5bf9a094ec3a5c8e51afa6f425251218` +The pinned SDK revision is `polymorfa/sdks@589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2` (the Calls client ships in the same package as `@polymorfa/sdk/calls`). Its coverage ledger is reconciled against the API contracts at `polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform diff --git a/package-lock.json b/package-lock.json index 37bdd14d..b3149252 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#70b671eb5bf9a094ec3a5c8e51afa6f425251218", + "@polymorfa/sdk": "github:polymorfa/sdks#589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2", "zod": "4.2.0" }, "bin": { @@ -997,7 +997,7 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#70b671eb5bf9a094ec3a5c8e51afa6f425251218", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2", "integrity": "sha512-vUknb1Jc6MaLkYlNHQPVN1SDmRGsdb0tcgnO+ql+PPNrA7gnm5rimAeNGnQZDTmtme6e3z+RriRUQB89wC9mJA==", "workspaces": [ "packages/calls", diff --git a/package.json b/package.json index 9fb9f1aa..925ee50b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#70b671eb5bf9a094ec3a5c8e51afa6f425251218", + "@polymorfa/sdk": "github:polymorfa/sdks#589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2", "zod": "4.2.0" }, "devDependencies": { From e4cfbc098868ca9683b91313f5a53ed2d9e6c6c4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 18 Sep 2026 14:22:37 +0200 Subject: [PATCH 32/34] feat(calls)!: replace self audio with conference mode and pin SDK 94bfd09 - Pin @polymorfa/sdk to polymorfa/sdks@94bfd09, which replaces the call settings field includeSelfAudio with conferenceMode. - calls settings set: replace --include-self-audio with --conference-mode/--no-conference-mode (on by default); calls settings get/set print "Conference mode". BREAKING CHANGE: --include-self-audio is removed; use --[no-]conference-mode. --- README.md | 9 +++-- docs/command-research/calls.md | 4 +- docs/development/bansafe-sdk-integration.md | 2 +- package.json | 2 +- src/commands/calls/settings/set.ts | 18 ++++----- src/services/calls.ts | 8 ++-- test/commands/calls.test.ts | 43 ++++++++++++--------- 7 files changed, 47 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index f8bd653d..0be27e33 100644 --- a/README.md +++ b/README.md @@ -461,7 +461,7 @@ polymorfa calls media --session support --place +15550100 \ --input prompt.pcm --leave-on-eof --output reply.pcm polymorfa calls settings get --session support -polymorfa calls settings set --session support --include-self-audio=true +polymorfa calls settings set --session support --no-conference-mode polymorfa calls settings set --session support --calls-enabled=false \ --expected-revision 3 polymorfa calls settings set --session support \ @@ -496,8 +496,11 @@ never answers or declines on its own. API key. `--calls-enabled=false` turns calling off for the session: placing, answering and joining calls then exit with code 3 (`calls_disabled`), incoming calls are declined, and calls in progress continue. - `--include-self-audio` controls whether the merged audio a - connection receives includes its own audio. It is off by default. + Conference mode is on by default: every participant you connect to a call + (apps, bots, and SIP trunk callers) hears the WhatsApp party and each other. + `--no-conference-mode` turns it off, so each participant hears only the + WhatsApp party; `--conference-mode` turns it back on. The WhatsApp party + always hears all of your participants, and nobody hears their own audio. `--inbound-route sip_trunk` with `--sip-trunk` also sends incoming calls to a SIP trunk; `--sip-claim` controls whether the trunk's answer claims the call. On a Cloud API session, `--host-cloud-api-calls=true` has Polymorfa diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index ca7541a6..45f0844d 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -4,7 +4,7 @@ - **Reviewed:** 2026-09-17 - **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` subpath) at `polymorfa/sdks` commit - `589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2` + `94bfd09825817f842434e206e6a77adb5cc0c414` ## Sources reviewed @@ -35,7 +35,7 @@ calls [--input ] [--output ] [--leave-on-eof] [--video] settings get --session - set --session --include-self-audio= + set --session [--[no-]conference-mode] [--calls-enabled=] ... ``` ## Operation mapping diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index c3e4aef3..b440d814 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -4,7 +4,7 @@ The BanSafe CLI commands depend on the matching typed SDK revision pinned in `package.json`. During coordinated SDK and CLI development, verify both worktrees without replacing that immutable dependency in either manifest: -The pinned SDK revision is `polymorfa/sdks@589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2` +The pinned SDK revision is `polymorfa/sdks@94bfd09825817f842434e206e6a77adb5cc0c414` (the Calls client ships in the same package as `@polymorfa/sdk/calls`). Its coverage ledger is reconciled against the API contracts at `polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform diff --git a/package.json b/package.json index 925ee50b..470404d1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2", + "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", "zod": "4.2.0" }, "devDependencies": { diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts index c1780f37..5ec33956 100644 --- a/src/commands/calls/settings/set.ts +++ b/src/commands/calls/settings/set.ts @@ -9,11 +9,11 @@ import { export default class CallsSettingsSet extends BaseCommand { static override description = - 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --expected-revision applies the change only if the settings still have that revision (see `calls settings get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. --include-self-audio controls whether the merged audio a connection receives includes its own audio (off by default); WhatsApp never hears its own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. --host-cloud-api-calls=true has Polymorfa Calls answer a Cloud API session’s incoming calls instead of your Graph API integration. Requires a server API key.' + 'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --expected-revision applies the change only if the settings still have that revision (see `calls settings get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. --conference-mode (on by default) lets every participant you connect to a call (apps, bots and SIP trunk callers) hear the WhatsApp party and each other; --no-conference-mode lets each hear only the WhatsApp party. The WhatsApp party always hears all of your participants, and nobody hears their own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk (SIP trunks are a beta), and --sip-claim controls whether the trunk’s answer claims the call. --host-cloud-api-calls=true has Polymorfa Calls answer a Cloud API session’s incoming calls instead of your Graph API integration. Requires a server API key.' static override examples = [ '<%= config.bin %> calls settings set --session support --calls-enabled=false', - '<%= config.bin %> calls settings set --session support --include-self-audio=true', + '<%= config.bin %> calls settings set --session support --no-conference-mode', '<%= config.bin %> calls settings set --session support --calls-enabled=false --expected-revision 3', '<%= config.bin %> calls settings set --session support --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', '<%= config.bin %> calls settings set --session support --inbound-route clients', @@ -24,6 +24,11 @@ export default class CallsSettingsSet extends BaseCommand { description: 'Allow calls on the session (false turns calling off).', options: ['true', 'false'], }), + 'conference-mode': Flags.boolean({ + allowNo: true, + description: + 'Let your participants hear each other as well as the WhatsApp party (--no-conference-mode: only the WhatsApp party).', + }), 'expected-revision': Flags.integer({ description: 'Apply the change only if the settings still have this revision.', @@ -34,11 +39,6 @@ export default class CallsSettingsSet extends BaseCommand { 'On a Cloud API session, answer incoming calls with Polymorfa Calls (false leaves them to your Graph API integration).', options: ['true', 'false'], }), - 'include-self-audio': Flags.string({ - description: - 'Include each connection’s own audio in the merged audio it receives.', - options: ['true', 'false'], - }), 'inbound-route': Flags.string({ description: 'Where incoming calls ring: clients, or sip_trunk to also send them to a SIP trunk.', @@ -72,9 +72,9 @@ export default class CallsSettingsSet extends BaseCommand { ...(flags['host-cloud-api-calls'] === undefined ? {} : {hostCloudApiCalls: flags['host-cloud-api-calls'] === 'true'}), - ...(flags['include-self-audio'] === undefined + ...(flags['conference-mode'] === undefined ? {} - : {includeSelfAudio: flags['include-self-audio'] === 'true'}), + : {conferenceMode: flags['conference-mode']}), ...(flags['inbound-route'] === undefined ? {} : { diff --git a/src/services/calls.ts b/src/services/calls.ts index 21713b49..12397c98 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -256,7 +256,7 @@ export interface CallSettingsChanges { /** Apply only if the settings still have this revision. */ readonly expectedRevision?: number readonly hostCloudApiCalls?: boolean - readonly includeSelfAudio?: boolean + readonly conferenceMode?: boolean readonly inboundRoute?: CallInboundRoute readonly sipClaim?: boolean readonly sipTrunkId?: string @@ -293,9 +293,9 @@ export async function updateCallSettings( ...(changes.callsEnabled === undefined ? {} : {callsEnabled: changes.callsEnabled}), - ...(changes.includeSelfAudio === undefined + ...(changes.conferenceMode === undefined ? {} - : {includeSelfAudio: changes.includeSelfAudio}), + : {conferenceMode: changes.conferenceMode}), ...(inboundRoute === undefined ? {} : {inboundRoute}), ...(changes.sipTrunkId === undefined ? {} @@ -394,7 +394,7 @@ export function renderCallSettings( return [ `Session\t${session}`, `Calls\t${settings.callsEnabled === false ? 'off' : 'on'}`, - `Include self audio\t${yesNo(settings.includeSelfAudio)}`, + `Conference mode\t${settings.conferenceMode === false ? 'off' : 'on'}`, `Incoming calls\t${settings.inboundRoute === 'sip_trunk' ? 'clients and SIP trunk' : 'clients'}`, ...(settings.inboundRoute === 'sip_trunk' ? [ diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 08904720..9c955756 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -313,7 +313,7 @@ describe('unified calls commands', () => { test('reads and replaces session call settings with a server API key', async () => { server = await startHttpServer({ body: { - data: {includeSelfAudio: true, updatedAt: '2026-09-16T10:00:00.000Z'}, + data: {conferenceMode: false, updatedAt: '2026-09-16T10:00:00.000Z'}, success: true, }, }) @@ -323,17 +323,17 @@ describe('unified calls commands', () => { ) const updated = await apiKey(() => runCommand( - 'calls settings set --session support/main --include-self-audio=true --json', + 'calls settings set --session support/main --no-conference-mode --json', ), ) expect(viewed.error).toBeUndefined() expect(viewed.stdout).toBe( - 'Session\tsupport/main\nCalls\ton\nInclude self audio\tyes\nIncoming calls\tclients\nCloud API calls answered by\tyour Graph API integration\nRevision\t0\nUpdated\t2026-09-16T10:00:00.000Z\n', + 'Session\tsupport/main\nCalls\ton\nConference mode\toff\nIncoming calls\tclients\nCloud API calls answered by\tyour Graph API integration\nRevision\t0\nUpdated\t2026-09-16T10:00:00.000Z\n', ) expect(updated.error).toBeUndefined() expect(jsonData(updated.stdout)).toEqual({ - includeSelfAudio: true, + conferenceMode: false, updatedAt: '2026-09-16T10:00:00.000Z', }) expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ @@ -341,13 +341,13 @@ describe('unified calls commands', () => { 'PUT /platform/sessions/support%2Fmain/call-settings', ]) expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ - includeSelfAudio: true, + conferenceMode: false, }) }) test('sends only the settings it is given', async () => { const routed = { - includeSelfAudio: true, + conferenceMode: true, inboundRoute: 'sip_trunk', revision: 5, sipClaim: false, @@ -356,10 +356,8 @@ describe('unified calls commands', () => { } server = await startHttpServer({body: {data: routed, success: true}}) - const selfAudio = await apiKey(() => - runCommand( - 'calls settings set --session support --include-self-audio=true', - ), + const conference = await apiKey(() => + runCommand('calls settings set --session support --conference-mode'), ) const trunk = await apiKey(() => runCommand( @@ -390,13 +388,14 @@ describe('unified calls commands', () => { runCommand('calls settings set --session support'), ) - for (const ok of [selfAudio, trunk, back, keepTrunk, hosting]) { + for (const ok of [conference, trunk, back, keepTrunk, hosting]) { expect(ok.error).toBeUndefined() } - expect(selfAudio.stdout).toContain('Incoming calls\tclients and SIP trunk') - expect(selfAudio.stdout).toContain(`SIP trunk\t${TRUNK_ID}`) - expect(selfAudio.stdout).toContain('SIP answer claims call\tno') - expect(selfAudio.stdout).toContain( + expect(conference.stdout).toContain('Conference mode\ton') + expect(conference.stdout).toContain('Incoming calls\tclients and SIP trunk') + expect(conference.stdout).toContain(`SIP trunk\t${TRUNK_ID}`) + expect(conference.stdout).toContain('SIP answer claims call\tno') + expect(conference.stdout).toContain( 'Cloud API calls answered by\tyour Graph API integration', ) expect(server.requests.map(({method}) => method)).toEqual([ @@ -409,7 +408,7 @@ describe('unified calls commands', () => { expect( server.requests.map(({body}) => JSON.parse(body) as unknown), ).toEqual([ - {includeSelfAudio: true}, + {conferenceMode: true}, {inboundRoute: 'sip_trunk', sipTrunkId: TRUNK_ID}, {inboundRoute: 'clients', sipClaim: false}, {inboundRoute: 'sip_trunk'}, @@ -425,7 +424,7 @@ describe('unified calls commands', () => { body: { data: { callsEnabled: false, - includeSelfAudio: false, + conferenceMode: true, inboundRoute: 'clients', revision: 4, sipClaim: true, @@ -485,7 +484,7 @@ describe('unified calls commands', () => { body: { data: { callsEnabled: false, - includeSelfAudio: false, + conferenceMode: true, inboundRoute: 'clients', revision: 1, sipClaim: true, @@ -541,7 +540,12 @@ describe('unified calls commands', () => { ) const value = await apiKey(() => runCommand( - 'calls settings set --session support --include-self-audio=yes --json', + 'calls settings set --session support --conference-mode=yes --json', + ), + ) + const retired = await apiKey(() => + runCommand( + 'calls settings set --session support --include-self-audio=true --json', ), ) @@ -550,6 +554,7 @@ describe('unified calls commands', () => { 'Platform API requests require POLYMORFA_API_KEY', ) expect(exitCode(value.error)).toBe(2) + expect(exitCode(retired.error)).toBe(2) expect(server.requests).toHaveLength(0) }) From 7c8d6c12821d9fa877472c50f488cabec979aecf Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 18 Sep 2026 14:33:47 +0200 Subject: [PATCH 33/34] chore(deps): regenerate the lockfile for SDK 94bfd09 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b3149252..1bccc14f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2", + "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", "zod": "4.2.0" }, "bin": { @@ -997,8 +997,8 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#589618b0ba3ac2683effcfdd5ba0dd4ec0a0fcf2", - "integrity": "sha512-vUknb1Jc6MaLkYlNHQPVN1SDmRGsdb0tcgnO+ql+PPNrA7gnm5rimAeNGnQZDTmtme6e3z+RriRUQB89wC9mJA==", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#94bfd09825817f842434e206e6a77adb5cc0c414", + "integrity": "sha512-C12O4fRO1i5poh2PpEB9FwaznqiiloOKV94jroS+NeTXFD9we2km53q/xp4Ftyq8GyWQUO7AY9pwPFsRiW3m6A==", "workspaces": [ "packages/calls", "packages/browser", From 045a66e21a651a9ae7bd14f5c4c752183ede6f4a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 18 Sep 2026 15:06:41 +0200 Subject: [PATCH 34/34] fix(calls): report standard input read errors and failed cancel cleanup - calls media --input -: capture standard input read errors like file errors and exit 2 instead of finishing normally. - calls media --place: if ending a call placed after Ctrl-C fails, report the error instead of a clean cancellation. --- src/commands/calls/media.ts | 14 ++-- src/services/calls.ts | 26 ++++--- test/commands/calls-sockets.test.ts | 107 ++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/commands/calls/media.ts b/src/commands/calls/media.ts index 65650e75..357f3fb2 100644 --- a/src/commands/calls/media.ts +++ b/src/commands/calls/media.ts @@ -141,13 +141,16 @@ export default class CallsMedia extends BaseCommand { if (file === undefined) process.stdout.write(pcm) else file.write(pcm) } + const input = flags.input === '-' ? process.stdin : inputFile + const inputName = inputPath ?? 'standard input' // A read error can arrive before the call connects and reading starts. + // Without this, a failed read would end the call as a normal finish. let inputError: unknown - inputFile?.on('error', (error: Error) => { + const onInputError = (error: Error): void => { inputError ??= error halt.abort() - }) - const input = flags.input === '-' ? process.stdin : inputFile + } + input?.on('error', onInputError) // Flush and close the output file once; its errors arrive asynchronously. let outputClosed = false @@ -178,9 +181,7 @@ export default class CallsMedia extends BaseCommand { ) await closeOutput() if (fileError !== undefined) throw outputFailed(flags.output, fileError) - if (inputError !== undefined && inputPath !== undefined) { - throw inputFailed(inputPath, inputError) - } + if (inputError !== undefined) throw inputFailed(inputName, inputError) report( {type: 'finished', ...summary}, `Finished (${summary.reason}): received ${summary.bytesReceived} bytes, sent ${summary.bytesSent} bytes.`, @@ -192,6 +193,7 @@ export default class CallsMedia extends BaseCommand { // Reading standard input stays blocked on a producer that never closes // it (a microphone, tail -f), which would keep the process alive. if (input === process.stdin) process.stdin.destroy() + input?.off('error', onInputError) await closeOutput() } if (fileError !== undefined) throw outputFailed(flags.output, fileError) diff --git a/src/services/calls.ts b/src/services/calls.ts index 12397c98..991c6990 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -995,6 +995,7 @@ export async function bridgeCallMedia( try { await client.connect() if (!finished && !client.connected) status(unreachable) + let cancelledCall: Call | undefined if (target !== undefined && !finished) { try { const call = await client.place(target, { @@ -1002,19 +1003,28 @@ export async function bridgeCallMedia( ...(input.video === undefined ? {} : {video: input.video}), }) if (finished) { - // Cancelled while placing: stop the callee's phone ringing. - await call.end().catch(() => undefined) - return await done + cancelledCall = call + } else { + status({type: 'placed', callId: call.id}) + attach(call) + // Events that arrived before place() returned are already applied, + // so the call may have ended without emitting to our listener. + if (call.ended) ended(call, call.endReason ?? 'unknown') } - status({type: 'placed', callId: call.id}) - attach(call) - // Events that arrived before place() returned are already applied, - // so the call may have ended without emitting to our listener. - if (call.ended) ended(call, call.endReason ?? 'unknown') } catch (error) { finish(callsClientError(error)) } } + if (cancelledCall !== undefined) { + // Cancelled while placing: stop the callee's phone ringing. If that + // fails the call may still ring, so report the failure rather than a + // clean cancellation. + try { + await cancelledCall.end() + } catch (error) { + throw callsClientError(error) + } + } return await done } finally { input.signal.removeEventListener('abort', onAbort) diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index eb620714..0475ad8d 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -467,6 +467,113 @@ describe('calls watch and media', () => { ]) }) + test('reports a failure to end a call placed after an interrupt', async () => { + const controller = new AbortController() + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({participant: 'server:default', type: 'ready'}) + }, + rest: async (request) => { + if (request.method === 'POST') { + controller.abort() + await delay(100) + return { + body: { + data: {callId: 'call_7', session: 'support', video: false}, + success: true, + }, + status: 201, + } + } + return { + body: { + data: null, + error: { + code: 'forbidden', + message: 'Ending this call is not allowed.', + type: 'permission_error', + }, + }, + status: 403, + } + }, + }) + + const outcome = bridgeCallMedia( + { + baseUrl: server.baseUrl, + credential: {type: 'apiKey', value: API_KEY}, + output: () => undefined, + place: '+15550100', + session: 'support', + signal: controller.signal, + }, + () => undefined, + ) + + await expect(outcome).rejects.toThrow('Ending this call is not allowed.') + expect(server.requests[0]?.method).toBe('POST') + expect(server.requests[1]).toMatchObject({ + method: 'DELETE', + url: '/messaging/voip/calls/call_7', + }) + }) + + test('exits with a configuration error when standard input fails', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + if (socket.path.startsWith('/voip/ws')) { + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_4', {from: '+15550100'}) + return + } + socket.sendJson({sampleRate: 16000, type: 'ready', video: false}) + for (;;) { + if (typeof (await socket.next()) === 'string') return + } + }, + rest: () => ({ + body: { + data: { + answered: true, + answeredBy: 'server:default', + exclusive: false, + }, + success: true, + }, + }), + }) + const failing = new Readable({ + read() { + this.destroy(new Error('EIO: i/o error, read')) + }, + }) + const original = Object.getOwnPropertyDescriptor(process, 'stdin') + Object.defineProperty(process, 'stdin', { + configurable: true, + value: failing, + }) + try { + const result = await apiKey(() => + runCommand( + `calls media call_4 --session support --input - --output ${join(directory, 'out.pcm')} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain( + 'Could not read audio from standard input: EIO: i/o error, read', + ) + expect(result.stderr).not.toContain('"finished"') + } finally { + if (original !== undefined) { + Object.defineProperty(process, 'stdin', original) + } + } + }) + test('exits after the call ends while standard input stays open', async () => { server = await startCallsServer({ onSocket: async (socket) => {