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 d21759af..0be27e33 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ access until `@polymorfa/cli` is released publicly. ## Requirements -- Node.js 20 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 @@ -443,10 +443,138 @@ 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 -Three focused Linked Device commands cover the exact Calls, LIDs, and Users -source tags: +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 --no-conference-mode +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 +``` + +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. `--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. + 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 + 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 + 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 +`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. If the `--output` file cannot be written, +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: + +```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 +``` + +### 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 +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 \ @@ -887,7 +1015,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-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..45f0844d --- /dev/null +++ b/docs/command-research/calls.md @@ -0,0 +1,134 @@ +# Calls command research + +- **Status:** Implemented against Calls contract revision 1, unpublished +- **Reviewed:** 2026-09-17 +- **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` + subpath) at `polymorfa/sdks` commit + `94bfd09825817f842434e206e6a77adb5cc0c414` + +## 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/sdk/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 [--[no-]conference-mode] [--calls-enabled=] ... +``` + +## 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` | +| `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 +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. + `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. + +## 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. The CLI as a whole requires Node.js 22 or newer. + +## 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. +- `@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/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/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index fe4075b2..b440d814 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@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 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 34159942..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,8 +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 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 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. The Calls client ships inside `@polymorfa/sdk`, +so the pinned Git commit installs it with no separate package. ## Internal boundaries @@ -541,6 +545,19 @@ business call reject +calls + place + accept + reject + leave + end + invite + watch + media + settings + get + set + lid resolve @@ -704,7 +721,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 @@ -1013,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 @@ -1045,6 +1069,9 @@ 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, and project-token metadata commands; diff --git a/package-lock.json b/package-lock.json index c7b2c17d..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#8803f0554314214519971cb3306e0c0efe21cb76", + "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", "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#94bfd09825817f842434e206e6a77adb5cc0c414", + "integrity": "sha512-C12O4fRO1i5poh2PpEB9FwaznqiiloOKV94jroS+NeTXFD9we2km53q/xp4Ftyq8GyWQUO7AY9pwPFsRiW3m6A==", "workspaces": [ "packages/calls", "packages/browser", diff --git a/package.json b/package.json index 397e2145..470404d1 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,7 +33,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#94bfd09825817f842434e206e6a77adb5cc0c414", "zod": "4.2.0" }, "devDependencies": { @@ -96,7 +96,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." @@ -194,6 +200,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/base-command.ts b/src/base-command.ts index c0d6c9f4..2a54b872 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -8,6 +8,7 @@ import { import { resolveCommandEnvironment, resolveListenerEnvironment, + type ResolvedEnvironment, } from './context/environment.js' import {presentError} from './errors/present.js' import {errorEnvelope, type OutputContext} from './output/contracts.js' @@ -97,15 +98,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' @@ -115,7 +108,6 @@ export abstract class BaseCommand extends Command { 'credential', ) } - this.registerSecret(environment.credential.value) return createMessagingClient(environment) } @@ -155,12 +147,33 @@ export abstract class BaseCommand extends Command { } } + /** 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 ? {} @@ -168,7 +181,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..357f3fb2 --- /dev/null +++ b/src/commands/calls/media.ts @@ -0,0 +1,251 @@ +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' + +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 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 => { + if (json) process.stderr.write(`${JSON.stringify(value)}\n`) + 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 = + 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 = + 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) + } + 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 + const onInputError = (error: Error): void => { + inputError ??= error + halt.abort() + } + input?.on('error', onInputError) + + // 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( + { + 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: halt.signal, + video: flags.video, + }, + (status) => report(status, renderMediaStatus(status, flags.session)), + ) + await closeOutput() + if (fileError !== undefined) throw outputFailed(flags.output, fileError) + if (inputError !== undefined) throw inputFailed(inputName, inputError) + 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() + // 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) + } +} + +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) + } +} + +/** 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}`, + 'output', + ) +} 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..da6e663c --- /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 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', + ] + + 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..5ec33956 --- /dev/null +++ b/src/commands/calls/settings/set.ts @@ -0,0 +1,97 @@ +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 = + '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 --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', + ] + + static override flags = { + 'calls-enabled': Flags.string({ + 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.', + 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'], + }), + '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 = '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['calls-enabled'] === undefined + ? {} + : {callsEnabled: flags['calls-enabled'] === 'true'}), + ...(flags['expected-revision'] === undefined + ? {} + : {expectedRevision: flags['expected-revision']}), + ...(flags['host-cloud-api-calls'] === undefined + ? {} + : {hostCloudApiCalls: flags['host-cloud-api-calls'] === 'true'}), + ...(flags['conference-mode'] === undefined + ? {} + : {conferenceMode: flags['conference-mode']}), + ...(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, + 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/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/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/errors/present.ts b/src/errors/present.ts index 050b7b79..80ef6573 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -69,7 +69,7 @@ export function presentError( error instanceof EventFilterError ? error.code : error instanceof PolymorfaError && error.code - ? error.code + ? redact(error.code, secrets) : `${classified.category}_error`, message, retryable: 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 711c4f23..4cd16195 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -31,6 +31,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, ): ClientInstance<'organization'> { diff --git a/src/services/calls.ts b/src/services/calls.ts new file mode 100644 index 00000000..991c6990 --- /dev/null +++ b/src/services/calls.ts @@ -0,0 +1,1130 @@ +import {randomBytes} from 'node:crypto' + +import { + CallClaimedError, + CallsApiError, + CallsAuthError, + CallsClient, + CallsError, + type Call, + type CallClaim, + type CallEndReason, + type CallState, +} from '@polymorfa/sdk/calls' +import { + PolymorfaAuthenticationError, + PolymorfaAuthorizationError, + PolymorfaConfigurationError, + PolymorfaConflictError, + PolymorfaConnectionError, + PolymorfaError, + PolymorfaNotFoundError, + PolymorfaRateLimitError, + PolymorfaServerError, + PolymorfaValidationError, + type MessagingClient, + type CallInboundRoute, + 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 interface CallSettingsChanges { + readonly callsEnabled?: boolean + /** Apply only if the settings still have this revision. */ + readonly expectedRevision?: number + readonly hostCloudApiCalls?: boolean + readonly conferenceMode?: boolean + readonly inboundRoute?: CallInboundRoute + readonly sipClaim?: boolean + readonly sipTrunkId?: string +} + +/** + * 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, + session: string, + changes: CallSettingsChanges, +): Promise> { + const name = validateSession(session) + const {expectedRevision, ...settings} = changes + if (Object.values(settings).every((value) => value === undefined)) { + throw new PolymorfaConfigurationError( + 'Provide at least one setting to change.', + 'calls-enabled', + ) + } + if (changes.inboundRoute === 'clients' && changes.sipTrunkId !== undefined) { + throw new PolymorfaConfigurationError( + '--sip-trunk requires --inbound-route sip_trunk.', + 'sip-trunk', + ) + } + // A trunk alone switches the route to it. + const inboundRoute = + changes.inboundRoute ?? + (changes.sipTrunkId === undefined ? undefined : 'sip_trunk') + const response = await client.voip.updateCallSettings(name, { + ...(changes.callsEnabled === undefined + ? {} + : {callsEnabled: changes.callsEnabled}), + ...(changes.conferenceMode === undefined + ? {} + : {conferenceMode: changes.conferenceMode}), + ...(inboundRoute === undefined ? {} : {inboundRoute}), + ...(changes.sipTrunkId === undefined + ? {} + : {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) +} + +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}`, + `Calls\t${settings.callsEnabled === false ? 'off' : 'on'}`, + `Conference mode\t${settings.conferenceMode === false ? 'off' : 'on'}`, + `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)}`, + ] + : []), + `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') +} + +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 + }) + // 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, + 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 + 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() + : call.leave() + stop.then( + () => finish(call.endReason ?? 'left'), + (error: unknown) => finish(callsClientError(error)), + ) + } + + 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) + call.on('error', (error) => { + lastCallError = error + if (!(error instanceof CallClaimedError)) { + status({type: 'notice', code: error.code, message: error.message}) + } + }) + 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) + 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}), + }) + 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', () => { + 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 => { + stopping = true + 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) + let cancelledCall: Call | undefined + if (target !== undefined && !finished) { + try { + const call = await client.place(target, { + exclusive: input.exclusive === true, + ...(input.video === undefined ? {} : {video: input.video}), + }) + if (finished) { + 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') + } + } 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) + 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/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-sockets.test.ts b/test/commands/calls-sockets.test.ts new file mode 100644 index 00000000..0475ad8d --- /dev/null +++ b/test/commands/calls-sockets.test.ts @@ -0,0 +1,1047 @@ +import { + link, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} 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' + +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({ + error: {category: 'authentication'}, + }) + 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('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('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('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) => { + 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 + 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({ + 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({ + error: {category: 'conflict', code: 'call_claimed'}, + }) + 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('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({ + error: { + category: 'authorization', + code: 'calls_disabled', + 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('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('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 + 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..9c955756 --- /dev/null +++ b/test/commands/calls.test.ts @@ -0,0 +1,616 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + startHttpServer, + withEnvironment, + type ResponseFixture, + type TestHttpServer, +} from '../helpers/http-server.js' + +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 + + 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({ + error: { + category: 'conflict', + code: 'call_claimed', + 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({ + error: {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: {conferenceMode: false, 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 --no-conference-mode --json', + ), + ) + + expect(viewed.error).toBeUndefined() + expect(viewed.stdout).toBe( + '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({ + conferenceMode: false, + 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({ + conferenceMode: false, + }) + }) + + test('sends only the settings it is given', async () => { + const routed = { + conferenceMode: true, + inboundRoute: 'sip_trunk', + revision: 5, + sipClaim: false, + sipTrunkId: TRUNK_ID, + updatedAt: '2026-09-16T10:00:00.000Z', + } + server = await startHttpServer({body: {data: routed, success: true}}) + + const conference = await apiKey(() => + runCommand('calls settings set --session support --conference-mode'), + ) + const trunk = 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 keepTrunk = await apiKey(() => + runCommand( + '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}`, + ), + ) + const empty = await apiKey(() => + runCommand('calls settings set --session support'), + ) + + for (const ok of [conference, trunk, back, keepTrunk, hosting]) { + expect(ok.error).toBeUndefined() + } + 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([ + 'PUT', + 'PUT', + 'PUT', + 'PUT', + 'PUT', + ]) + expect( + server.requests.map(({body}) => JSON.parse(body) as unknown), + ).toEqual([ + {conferenceMode: true}, + {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) + }) + + test('guards a change with the expected revision and exits 4 when stale', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + callsEnabled: false, + conferenceMode: true, + 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([ + { + body: { + data: { + callsEnabled: false, + conferenceMode: true, + 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( + 400, + 'invalid_parameter', + 'sipTrunkId is required when inboundRoute is sip_trunk.', + ), + ) + + const result = await apiKey(() => + runCommand( + 'calls settings set --session support --inbound-route sip_trunk', + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('sipTrunkId is required') + }) + + 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 --conference-mode=yes --json', + ), + ) + const retired = await apiKey(() => + runCommand( + 'calls settings set --session support --include-self-audio=true --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(exitCode(retired.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/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 +} diff --git a/test/errors/present.test.ts b/test/errors/present.test.ts index ac4571dc..3e62839d 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' @@ -99,4 +100,23 @@ describe('presentError', () => { retryable: true, }) }) + + 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', + retryable: false, + }) + }) }) diff --git a/test/helpers/calls-server.ts b/test/helpers/calls-server.ts new file mode 100644 index 00000000..8ea7fa86 --- /dev/null +++ b/test/helpers/calls-server.ts @@ -0,0 +1,248 @@ +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 | Promise +} + +/** + * 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) + 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, + } + 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))), + } +}