diff --git a/README.md b/README.md index 349fe3db..3ea63546 100644 --- a/README.md +++ b/README.md @@ -704,6 +704,24 @@ polymorfa calls retention set --policy compliance --expected-revision 0 polymorfa calls retention set --policy custom --days 14 --expected-revision 1 --yes ``` +This source revision also adds `calls place-group` and `calls ring`. They require +the matching Calls API and a published SDK version that includes group controls; +the release must update the pinned SDK dependency before these commands ship. + +```shell +polymorfa calls place-group --session support --to +15550100 --to +15550101 +polymorfa calls place-group --session support --group-id 9007199254740996 +polymorfa calls ring call_123 +15550101 +``` + +Group placement accepts 2 to 31 distinct recipients and checks every recipient's +calling permissions. Use `--group-id` instead of `--to` to call every remote +member of an existing group using its public ID. The Number must belong to the +group, and it must have 2 to 31 remote members. Membership changes during +placement refuse the call. Group calls require a Linked Device Number. `calls ring` +only rings a non-connected participant already in the call roster; successful +submission does not mean that the participant answered. + Every inbound call rings until a participant answers or declines it. The CLI never answers or declines on its own. @@ -802,7 +820,9 @@ 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. +command carries audio only. `--muted` receives audio with this connection muted; +it cannot be combined with `--input`. The command refuses `--muted` before +connecting when its pinned Calls SDK lacks media-control support. Convert audio with any tool that writes raw PCM, for example: @@ -943,6 +963,10 @@ Both commands filter with `--session`, `--direction` (`inbound` or ### SIP trunks +`sip-trunk view` displays a null `outbound.fromUser` as automatic caller +identity. Set `outbound.fromUser` in your trunk JSON when your PBX needs a fixed +From user. + SIP trunks connect a PBX to WhatsApp calls. They are part of Calls and need no enrollment. Commands require a server API key. @@ -1996,3 +2020,42 @@ Invocation input contains an optional `deploymentId`, `trigger` (`test` or `http Use `function invocation list FUNCTION_UUID` and `function invocation view FUNCTION_UUID INVOCATION_UUID` to reconcile a result. Invocation inputs, responses and customer log text are not retained. `--json` includes the transient result when the initial invocation returns it. The default display prints metadata and HTTP status without response content. An uncertain, failed, running or unavailable invocation, or an HTTP response of 400 or higher, exits with code 8 after printing its receipt. Check customer effects before using a new key unless `retryable` explicitly says execution did not start. Function, deployment, secret and invocation lists accept `--limit 1..100` and `--before UUID`. Deployment lists return metadata without source, including with `--json`. `function deployment view` exposes source with `--json`; secret reads always return metadata. + +### Call reaction and hand commands + +`calls react CALL --connection-id CONNECTION --emoji '๐Ÿ‘'` sends a reaction; +`--clear` clears it. `calls hand CALL --connection-id CONNECTION --raised` +raises the Number's hand; omit `--raised` to lower it. A server credential may +name `--participant`; a client token acts as its own participant. The named +connection must still be attached to the call. + +These source commands require the SDK release containing `sendReaction` and +`setHandRaised`, followed by an exact registry dependency update. Until that +release is pinned, they return a specific SDK dependency error. Local package +tests do not establish published availability. Reactions are transient and +are never retried automatically. Hand state is shared by the Number's clients. + +The source group-placement and participant re-ring commands also require the +matching SDK publication. Until its exact registry version is pinned, they +refuse before sending a request. Testing a local package does not establish +registry or deployed availability. + +### Stored call records + +```sh +polymorfa calls list --outcome missed --limit 25 --json +polymorfa calls list --outcome missed --limit 25 --cursor CURSOR --json +polymorfa calls view CALL_ID --json +``` + +`calls list` reads one page through the existing Calls records API. Reuse the +same filters when following a cursor. These reads need `sessions:read` and a +server credential. Project tokens only read calls originally owned by their +project. Human output labels unmeasured duration as `unknown`. + +The source `calls view` command requires the SDK publication containing +`Client.calls.retrieve` and the matching API deployment. Until that exact +registry release is pinned, it reports an SDK dependency error before transport. +Its JSON output includes bounded event history, participant and connection +lifetimes, and allowlisted diagnostics. It does not retrieve media or webhook +deliveries. Local package tests do not establish published availability. diff --git a/src/commands/calls/hand.ts b/src/commands/calls/hand.ts new file mode 100644 index 00000000..b219706d --- /dev/null +++ b/src/commands/calls/hand.ts @@ -0,0 +1,49 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {setCallHand} from '../../services/calls.js' + +export default class CallsHand extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + } + + static override description = + 'Raise or lower the Numberโ€™s hand. The Number has one hand state shared across its application connections.' + + static override examples = [ + '<%= config.bin %> calls hand call_123 --connection-id conn_0123456789 --raised', + ] + + static override flags = { + 'connection-id': Flags.string({ + description: + 'Connection to close: 8 to 64 letters, digits, underscores, or hyphens.', + required: true, + }), + raised: Flags.boolean({description: 'Raise the hand; omit to lower it.'}), + participant: Flags.string({ + description: + 'Server API keys only: participant that owns the connection (default "default").', + }), + } + + static override summary = 'Raise or lower a call hand.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsHand) + const environment = await this.messagingEnvironment(flags) + const result = await setCallHand( + createMessagingClient(environment), + environment.credential, + args.callId, + { + connectionId: flags['connection-id'], + raised: flags.raised, + participant: flags.participant, + }, + ) + this.writeValue(result, `Updated hand on call ${args.callId}.`, flags) + } +} diff --git a/src/commands/calls/list.ts b/src/commands/calls/list.ts new file mode 100644 index 00000000..795e5b9e --- /dev/null +++ b/src/commands/calls/list.ts @@ -0,0 +1,51 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import {callFilterFlags} from '../../developer/flags.js' +import {developerRead} from '../../developer/output.js' +import { + CallAnalyticsApi, + callFilterQuery, + projectQuery, + renderCallRecords, +} from '../../services/call-analytics.js' + +export default class CallsList extends BaseCommand { + static override description = + 'List one page of stored call records, newest first. Requires sessions:read and a server credential. Project tokens read only their original owning project. Reuse the same filters with --cursor.' + static override examples = [ + '<%= config.bin %> calls list --outcome missed --limit 25', + '<%= config.bin %> calls list --project PROJECT_ID --json', + ] + static override flags = { + ...callFilterFlags, + limit: Flags.integer({ + min: 1, + max: 100, + default: 25, + description: 'Records per page.', + }), + cursor: Flags.string({ + description: 'Cursor from the previous page; reuse the same filters.', + }), + } + async run(): Promise { + const {flags} = await this.parse(CallsList) + const environment = await this.developerEnvironment(flags, { + inferOwner: true, + }) + const response = await new CallAnalyticsApi(environment).list({ + ...callFilterQuery(flags), + ...projectQuery(environment.context), + limit: flags.limit, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + }) + this.writeValue( + developerRead(response.data, response.metadata, environment.context, { + nextCursor: response.nextCursor, + hasMore: response.hasMore, + }), + renderCallRecords(response.data, response.nextCursor), + flags, + ) + } +} diff --git a/src/commands/calls/media.ts b/src/commands/calls/media.ts index 357f3fb2..2bf38f99 100644 --- a/src/commands/calls/media.ts +++ b/src/commands/calls/media.ts @@ -1,3 +1,4 @@ +import {assertCallMediaMuteSupported} from '../../services/call-media-controls.js' import {createWriteStream, type ReadStream, type WriteStream} from 'node:fs' import {open, stat} from 'node:fs/promises' import {once} from 'node:events' @@ -36,6 +37,11 @@ export default class CallsMedia extends BaseCommand { ] static override flags = { + muted: Flags.boolean({ + exclusive: ['input'], + description: + 'Mute this connection while receiving call audio. Requires media-control support.', + }), exclusive: Flags.boolean({ description: 'Claim the call so no other participant can answer or join it.', @@ -74,6 +80,7 @@ export default class CallsMedia extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(CallsMedia) + if (flags.muted) assertCallMediaMuteSupported() if (flags.output === '-' && process.stdout.isTTY) { throw new PolymorfaConfigurationError( 'Received audio is binary. Redirect standard output or pass --output .', @@ -168,6 +175,7 @@ export default class CallsMedia extends BaseCommand { callId: args.callId, credential: environment.credential, exclusive: flags.exclusive, + muted: flags.muted, input, leaveOnEnd: flags['leave-on-eof'], output, diff --git a/src/commands/calls/place-group.ts b/src/commands/calls/place-group.ts new file mode 100644 index 00000000..6dae6da8 --- /dev/null +++ b/src/commands/calls/place-group.ts @@ -0,0 +1,48 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {placeCall, renderPlacedCall} from '../../services/calls.js' +import CallsPlace from './place.js' + +export default class CallsPlaceGroup extends BaseCommand { + static override description = + 'Place one WhatsApp group call by public group ID or to 2 to 31 distinct recipients from a Linked Device number. Each recipient must pass the numberโ€™s calling restrictions and your teamโ€™s destination policy. Cloud API numbers refuse this operation.' + static override examples = [ + '<%= config.bin %> calls place-group --to +15550100 --to +15550101 --session support', + '<%= config.bin %> calls place-group --group-id 9007199254740996 --session support', + ] + static override flags = { + ...CallsPlace.flags, + 'group-id': Flags.string({ + description: + 'Public group ID. Calls its 2 to 31 remote members after live consent checks.', + exclusive: ['to'], + }), + to: Flags.string({ + description: + 'E.164 phone number or public user ID. Repeat for each recipient.', + multiple: true, + exclusive: ['group-id'], + }), + } + static override summary = 'Place a WhatsApp group call.' + + async run(): Promise { + const {flags} = await this.parse(CallsPlaceGroup) + const environment = await this.messagingEnvironment(flags) + const result = await placeCall( + createMessagingClient(environment), + environment.credential, + { + to: flags.to, + groupId: flags['group-id'], + session: flags.session, + video: flags.video, + exclusive: flags.exclusive, + participant: flags.participant, + }, + ) + this.writeValue(result, renderPlacedCall(result.data), flags) + } +} diff --git a/src/commands/calls/react.ts b/src/commands/calls/react.ts new file mode 100644 index 00000000..4a2ee91b --- /dev/null +++ b/src/commands/calls/react.ts @@ -0,0 +1,55 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {reactToCall} from '../../services/calls.js' + +export default class CallsReact extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + } + + static override description = + 'Send a transient call reaction, or clear it. The connection must belong to the acting participant.' + + static override examples = [ + '<%= config.bin %> calls react call_123 --connection-id conn_0123456789 --emoji ๐Ÿ‘', + ] + + static override flags = { + 'connection-id': Flags.string({ + description: + 'Connection to close: 8 to 64 letters, digits, underscores, or hyphens.', + required: true, + }), + emoji: Flags.string({ + description: 'Reaction emoji; omit with --clear.', + exclusive: ['clear'], + }), + clear: Flags.boolean({description: 'Clear the previous reaction.'}), + participant: Flags.string({ + description: + 'Server API keys only: participant that owns the connection (default "default").', + }), + } + + static override summary = 'Send or clear a call reaction.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsReact) + if (!flags.clear && flags.emoji === undefined) + this.error('Use --emoji or --clear.') + const environment = await this.messagingEnvironment(flags) + const result = await reactToCall( + createMessagingClient(environment), + environment.credential, + args.callId, + { + connectionId: flags['connection-id'], + emoji: flags.clear ? '' : (flags.emoji ?? ''), + participant: flags.participant, + }, + ) + this.writeValue(result, `Sent reaction on call ${args.callId}.`, flags) + } +} diff --git a/src/commands/calls/ring.ts b/src/commands/calls/ring.ts new file mode 100644 index 00000000..03c1adb4 --- /dev/null +++ b/src/commands/calls/ring.ts @@ -0,0 +1,30 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createMessagingClient} from '../../sdk/client.js' +import {ringCallParticipant} from '../../services/calls.js' + +export default class CallsRing extends BaseCommand { + static override args = { + callId: Args.string({description: 'Call ID.', required: true}), + target: Args.string({ + description: + 'E.164 phone number or public user ID already in the call roster.', + required: true, + }), + } + static override description = + 'Ring a non-connected WhatsApp participant already in the call roster. Re-ring applies the same destination permissions and restrictions as an invitation. It does not add a participant or mark them connected.' + static override examples = ['<%= config.bin %> calls ring call_123 +15550100'] + static override summary = 'Ring an existing call participant again.' + + async run(): Promise { + const {args, flags} = await this.parse(CallsRing) + const result = await ringCallParticipant( + createMessagingClient(await this.messagingEnvironment(flags)), + args.callId, + args.target, + ) + this.writeValue(result, `Ring requested for call ${args.callId}.`, flags) + } +} diff --git a/src/commands/calls/view.ts b/src/commands/calls/view.ts new file mode 100644 index 00000000..c915cdc8 --- /dev/null +++ b/src/commands/calls/view.ts @@ -0,0 +1,32 @@ +import {Args} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import {developerRead} from '../../developer/output.js' +import { + CallAnalyticsApi, + projectQuery, + renderCallDetail, +} from '../../services/call-analytics.js' + +export default class CallsView extends BaseCommand { + static override description = + 'Read one stored call and its bounded metadata history. Requires sessions:read and a server credential. Use --json for participants, connections and reported diagnostics. No audio, video, event payload or webhook delivery data is returned. Requires the published SDK and API revision that includes call detail.' + static override args = { + id: Args.string({required: true, description: 'Call ID from calls list.'}), + } + static override examples = ['<%= config.bin %> calls view CALL_ID --json'] + async run(): Promise { + const {args, flags} = await this.parse(CallsView) + const environment = await this.developerEnvironment(flags, { + inferOwner: true, + }) + const response = await new CallAnalyticsApi(environment).retrieve( + args.id, + projectQuery(environment.context), + ) + this.writeValue( + developerRead(response.data, response.metadata, environment.context), + renderCallDetail(response.data), + flags, + ) + } +} diff --git a/src/services/call-analytics.ts b/src/services/call-analytics.ts index b9cef7df..9f223232 100644 --- a/src/services/call-analytics.ts +++ b/src/services/call-analytics.ts @@ -1,6 +1,9 @@ import { Client, PolymorfaServerError, + PolymorfaConfigurationError, + type ApiResponse, + type CallRecord, PolymorfaValidationError, type CallStats, type CallStatsHeatmapCell, @@ -115,6 +118,63 @@ export class CallAnalyticsApi { } } + async list(query: Query) { + const page = await this.calls.list(query) + return { + data: page.items, + metadata: page.response.metadata, + nextCursor: page.nextCursor ?? null, + hasMore: page.hasMore, + } + } + + async retrieve(callId: string, query: Query) { + if (!/^[A-Za-z0-9_-]{1,128}$/.test(callId)) + throw invalid( + 'Call ID must contain 1 to 128 letters, digits, underscores or hyphens.', + ) + const calls = this.calls as typeof this.calls & { + retrieve?: ( + id: string, + params: Query, + ) => Promise> + } + if (typeof calls.retrieve !== 'function') { + throw new PolymorfaConfigurationError( + 'Call detail requires a published SDK version that includes Client.calls.retrieve. This CLI has not activated a source-only SDK pin.', + 'sdk', + ) + } + const response = await calls.retrieve(callId, query) + const data = response.data + if ( + !data || + typeof data.call?.callId !== 'string' || + typeof data.call.sessionId !== 'string' || + typeof data.call.state !== 'string' || + typeof data.call.live !== 'boolean' || + !Array.isArray(data.history?.events) || + !data.history.events.every( + (event: unknown) => + event !== null && + typeof event === 'object' && + 'eventId' in event && + 'type' in event && + 'occurredAt' in event && + typeof event.eventId === 'string' && + typeof event.type === 'string' && + typeof event.occurredAt === 'string', + ) || + typeof data.history.truncated !== 'boolean' + ) { + throw new PolymorfaServerError( + 'The API returned an invalid call detail.', + {code: 'invalid_response'}, + ) + } + return response + } + async stats( query: Query, ): Promise<{readonly data: CallStats; readonly metadata: ResponseMetadata}> { @@ -493,3 +553,64 @@ function statsData(value: unknown): CallStats { function invalid(message: string): PolymorfaValidationError { return new PolymorfaValidationError(message, {code: 'invalid_call_filter'}) } + +/** Fields used by human output; JSON retains the complete SDK response. */ +export interface StoredCallDetail { + readonly call: { + readonly callId: string + readonly sessionId: string + readonly state: string + readonly live: boolean + } + readonly history: { + readonly events: readonly { + readonly eventId: string + readonly type: string + readonly occurredAt: string + }[] + readonly truncated: boolean + } +} + +export function renderCallRecords( + records: readonly CallRecord[], + nextCursor: string | null, +): string { + const lines = + records.length === 0 + ? ['No call records found.'] + : [ + 'Call ID\tNumber\tDirection\tOutcome\tConnected seconds', + ...records.map((record) => + [ + record.callId, + record.sessionId, + record.direction, + record.outcome, + record.durationSeconds ?? 'unknown', + ] + .map(String) + .map(terminalText) + .join('\t'), + ), + ] + if (nextCursor !== null) + lines.push(`More records: --cursor ${terminalText(nextCursor)}`) + return lines.join('\n') +} + +export function renderCallDetail(detail: StoredCallDetail): string { + return [ + `Call\t${terminalText(detail.call.callId)}`, + `Number\t${terminalText(detail.call.sessionId)}`, + `State\t${terminalText(detail.call.state)}`, + `Live\t${detail.call.live ? 'yes' : 'no'}`, + ...detail.history.events.map( + (event) => + `${terminalText(event.occurredAt)}\t${terminalText(event.type)}\t${terminalText(event.eventId)}`, + ), + ...(detail.history.truncated + ? ['History is truncated; some events were not retained.'] + : []), + ].join('\n') +} diff --git a/src/services/call-media-controls.ts b/src/services/call-media-controls.ts new file mode 100644 index 00000000..6c7259de --- /dev/null +++ b/src/services/call-media-controls.ts @@ -0,0 +1,32 @@ +import {Call} from '@polymorfa/sdk/calls' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +interface ConnectionMediaControls { + setMediaState(update: {audioMuted: boolean}): Promise<{audioMuted: boolean}> +} + +export function assertCallMediaMuteSupported( + candidate: object = Call.prototype, +): asserts candidate is ConnectionMediaControls { + if ( + typeof (candidate as Partial).setMediaState !== + 'function' + ) { + throw new PolymorfaConfigurationError( + 'This CLI build needs a published Calls SDK with media-control support before --muted can be used.', + 'muted', + ) + } +} + +/** Set only the connection held by this Call; never target a foreign connection. */ +export async function muteCallMedia(call: object): Promise { + assertCallMediaMuteSupported(call) + const state = await call.setMediaState({audioMuted: true}) + if (!state.audioMuted) { + throw new PolymorfaConfigurationError( + 'The call did not confirm the requested mute state.', + 'muted', + ) + } +} diff --git a/src/services/calls.ts b/src/services/calls.ts index 68ade6ef..c6f5bfb2 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -1,3 +1,7 @@ +import { + assertCallMediaMuteSupported, + muteCallMedia, +} from './call-media-controls.js' import {randomBytes} from 'node:crypto' import { @@ -138,30 +142,93 @@ export interface PlaceCallInput { readonly exclusive?: boolean | undefined readonly participant?: string | undefined readonly session?: string | undefined - readonly to: string + readonly to?: string | readonly string[] | undefined + readonly groupId?: string | undefined readonly video?: boolean | undefined } +interface GroupCallsResource { + place( + input: ( + | {participants: readonly string[]; groupId?: never} + | {groupId: string; participants?: never} + ) & { + session?: string + video?: boolean + exclusive?: boolean + participant?: string + }, + ): ReturnType + ringParticipant( + callId: string, + input: {to: string}, + ): Promise<{data: SuccessResponse; metadata: ResponseMetadata}> +} + +function groupResource(value: unknown): GroupCallsResource { + if ( + value === null || + typeof value !== 'object' || + !('place' in value) || + typeof value.place !== 'function' || + !('ringParticipant' in value) || + typeof value.ringParticipant !== 'function' + ) { + throw new PolymorfaConfigurationError( + 'Group calls and participant re-ring require a published SDK version with these controls. This CLI build has not received that SDK release.', + 'sdk', + ) + } + return value as GroupCallsResource +} + export async function placeCall( client: MessagingClient, credential: CallsCredential, input: PlaceCallInput, ): Promise> { - const to = validateCallTarget(input.to) + const groupId = input.groupId + if ( + groupId !== undefined && + (input.to !== undefined || !/^[1-9][0-9]{0,18}$/.test(groupId)) + ) { + throw new PolymorfaValidationError( + 'Provide one public group ID without --to.', + ) + } + if (groupId === undefined && input.to === undefined) + throw new PolymorfaValidationError('Provide --to or --group-id.') + const to = + typeof input.to === 'string' + ? validateCallTarget(input.to) + : input.to?.map((target) => validateCallTarget(target)) + if ( + to !== undefined && + typeof to !== 'string' && + (to.length < 2 || to.length > 31 || new Set(to).size !== to.length) + ) { + throw new PolymorfaValidationError( + 'A group call needs 2 to 31 distinct participants.', + ) + } const session = requireServerSession( credential, input.session, 'Placing a call', ) const participant = validateParticipant(credential, input.participant) + const options = { + ...(session === undefined ? {} : {session}), + ...(input.video === undefined ? {} : {video: input.video}), + ...(input.exclusive === undefined ? {} : {exclusive: input.exclusive}), + ...(participant === undefined ? {} : {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}), - }), + groupId + ? groupResource(client.voip).place({groupId, ...options}) + : typeof to === 'string' + ? client.voip.place({to, ...options}) + : groupResource(client.voip).place({participants: to!, ...options}), ) return result(response.data.data, response.metadata) } @@ -247,6 +314,19 @@ export async function inviteToCall( return result(response.data.data, response.metadata) } +export async function ringCallParticipant( + client: MessagingClient, + callId: string, + target: string, +): Promise> { + validateCallId(callId) + const to = validateCallTarget(target, 'target') + const response = await callRequest(() => + groupResource(client.voip).ringParticipant(callId, {to}), + ) + return result(response.data, response.metadata) +} + export async function viewCallSettings( client: MessagingClient, session: string, @@ -914,6 +994,7 @@ export async function watchCalls( } export interface CallMediaInput extends CallsClientInput { + readonly muted?: boolean | undefined /** Answer this inbound call; without it, the next inbound call. */ readonly callId?: string | undefined readonly exclusive?: boolean | undefined @@ -974,6 +1055,7 @@ export async function bridgeCallMedia( const target = input.place === undefined ? undefined : validateCallTarget(input.place) if (input.callId !== undefined) validateCallId(input.callId) + if (input.muted === true) assertCallMediaMuteSupported() const client = createCallsClient(input, 'Carrying call media') let bytesReceived = 0 @@ -1071,36 +1153,41 @@ export async function bridgeCallMedia( 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 start = async (): Promise => { + if (input.muted === true) await muteCallMedia(call) + if (finished || call.ended) return + 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) + }, + ) + } + void start().catch((error: unknown) => finish(callsClientError(error))) }) } @@ -1305,3 +1392,82 @@ function samplesFor(sampleRate: number, milliseconds: number): number { function bytesFor(sampleRate: number, milliseconds: number): number { return samplesFor(sampleRate, milliseconds) * 2 } + +export type CallReactionEmoji = '' | '๐Ÿ‘' | 'โค๏ธ' | '๐Ÿ˜‚' | '๐Ÿ˜ฎ' | '๐Ÿ˜ข' | '๐Ÿ™' +interface SocialInput { + readonly connectionId: string + readonly participant?: string | undefined +} +type SocialReply = {data: SuccessResponse; metadata: ResponseMetadata} +interface SocialCallsResource { + sendReaction( + callId: string, + input: { + connectionId: string + participant?: string + emoji: CallReactionEmoji + }, + ): Promise + setHandRaised( + callId: string, + input: {connectionId: string; participant?: string; raised: boolean}, + ): Promise +} +function socialResource(value: unknown): SocialCallsResource { + if ( + value === null || + typeof value !== 'object' || + !('sendReaction' in value) || + typeof value.sendReaction !== 'function' || + !('setHandRaised' in value) || + typeof value.setHandRaised !== 'function' + ) { + throw new PolymorfaConfigurationError( + 'Call reactions and hands require a published SDK version with these controls. This CLI build has not received that SDK release.', + 'sdk', + ) + } + return value as SocialCallsResource +} +export async function reactToCall( + client: MessagingClient, + credential: CallsCredential, + callId: string, + input: SocialInput & {readonly emoji: string}, +): Promise> { + validateCallId(callId) + const connectionId = validateConnectionId(input.connectionId) + const participant = validateParticipant(credential, input.participant) + if (!['', '๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ™'].includes(input.emoji)) + throw new PolymorfaConfigurationError( + 'Reaction must be ๐Ÿ‘, โค๏ธ, ๐Ÿ˜‚, ๐Ÿ˜ฎ, ๐Ÿ˜ข, ๐Ÿ™, or empty to clear.', + 'emoji', + ) + const resource = socialResource(client.voip) + const response = await callRequest(() => + resource.sendReaction(callId, { + connectionId, + emoji: input.emoji as CallReactionEmoji, + ...(participant === undefined ? {} : {participant}), + }), + ) + return result(response.data, response.metadata) +} +export async function setCallHand( + client: MessagingClient, + credential: CallsCredential, + callId: string, + input: SocialInput & {readonly raised: boolean}, +): Promise> { + validateCallId(callId) + const connectionId = validateConnectionId(input.connectionId) + const participant = validateParticipant(credential, input.participant) + const response = await callRequest(() => + socialResource(client.voip).setHandRaised(callId, { + connectionId, + raised: input.raised, + ...(participant === undefined ? {} : {participant}), + }), + ) + return result(response.data, response.metadata) +} diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index 6cee3c09..1ecd9db3 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -209,7 +209,7 @@ export function renderSipTrunk(trunk: SipTrunk): string { `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'}`, + `From user\t${trunk.outbound.fromUser ?? 'caller identity (automatic)'}`, ) } if (trunk.inbound !== null) { diff --git a/test/commands/call-media-controls.test.ts b/test/commands/call-media-controls.test.ts new file mode 100644 index 00000000..5aa0321f --- /dev/null +++ b/test/commands/call-media-controls.test.ts @@ -0,0 +1,23 @@ +import {describe, expect, test, vi} from 'vitest' +import { + assertCallMediaMuteSupported, + muteCallMedia, +} from '../../src/services/call-media-controls.js' + +describe('connection-local call media mute', () => { + test('refuses an older SDK before any call effect', () => { + expect(() => assertCallMediaMuteSupported({})).toThrow( + 'published Calls SDK', + ) + }) + test('sets only audioMuted and waits for authoritative confirmation', async () => { + const setMediaState = vi.fn(() => Promise.resolve({audioMuted: true})) + await muteCallMedia({setMediaState}) + expect(setMediaState).toHaveBeenCalledWith({audioMuted: true}) + }) + test('does not replay an uncertain media control', async () => { + const setMediaState = vi.fn(() => Promise.reject(new Error('unknown'))) + await expect(muteCallMedia({setMediaState})).rejects.toThrow('unknown') + expect(setMediaState).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/commands/call-records.test.ts b/test/commands/call-records.test.ts new file mode 100644 index 00000000..c19fd812 --- /dev/null +++ b/test/commands/call-records.test.ts @@ -0,0 +1,157 @@ +import {runCommand} from '@oclif/test' +import {Client} from '@polymorfa/sdk' +import {afterEach, describe, expect, test} from 'vitest' +import {renderCallDetail} from '../../src/services/call-analytics.js' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROJECT = '11111111-1111-4111-8111-111111111111' +const hasDetail = + 'retrieve' in + new Client({credential: {type: 'organizationApiKey', value: API_KEY}}).calls +const record = { + callId: 'call_1', + projectId: PROJECT, + sessionId: 'support', + direction: 'inbound', + upstream: 'linked_device', + outcome: 'answered', + state: 'ended', + hasVideo: false, + peerRef: null, + startedAt: '2026-09-26T10:00:00Z', + connectedAt: null, + endedAt: '2026-09-26T10:01:00Z', + durationSeconds: null, + endReason: null, +} + +describe('stored call record commands', () => { + let server: TestHttpServer | undefined + afterEach(async () => { + await server?.close() + server = undefined + }) + async function run(command: string) { + if (!server) throw new Error('missing test server') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand(command), + ) + } + test('reads one bounded page and preserves filters and the next cursor', async () => { + server = await startHttpServer({ + body: {data: [record], page: {nextCursor: 'cursor_2', hasMore: true}}, + }) + const result = await run( + `calls list --project ${PROJECT} --outcome answered --limit 5 --cursor cursor_1 --json`, + ) + expect(result.error).toBeUndefined() + const url = new URL(server.requests[0]?.url ?? '', 'http://localhost') + expect(url.pathname).toBe('/platform/calls') + expect(Object.fromEntries(url.searchParams)).toEqual({ + projectId: PROJECT, + outcome: 'answered', + limit: '5', + cursor: 'cursor_1', + }) + expect(JSON.parse(result.stdout)).toMatchObject({ + data: [record], + page: {nextCursor: 'cursor_2', hasMore: true}, + }) + expect(server.requests).toHaveLength(1) + }) + test('prints unknown duration and rejects invalid input before a request', async () => { + server = await startHttpServer({ + body: {data: [record], page: {nextCursor: null, hasMore: false}}, + }) + const result = await run('calls list') + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + 'call_1\tsupport\tinbound\tanswered\tunknown', + ) + for (const command of ['calls list --limit 101', 'calls view invalid.id']) { + expect((await run(command)).error).toBeDefined() + } + expect(server.requests).toHaveLength(1) + }) + test.skipIf(hasDetail)( + 'refuses detail on an older registry SDK before transport', + async () => { + server = await startHttpServer({body: {data: {}}}) + const result = await run('calls view call_1 --json') + expect(result.error).toBeDefined() + expect(result.stderr).toContain('published SDK version') + expect(server.requests).toHaveLength(0) + }, + ) + test.skipIf(!hasDetail)( + 'reads detail through the SDK and rejects an invalid shape', + async () => { + const detail = { + call: {...record, live: false}, + history: { + events: [ + { + eventId: 'event_1', + type: 'call.ended', + occurredAt: record.endedAt, + }, + ], + truncated: true, + }, + telemetry: {status: 'unknown'}, + participants: [], + connections: [], + appReports: {status: 'none'}, + } + server = await startHttpServer({body: {data: detail}}) + const result = await run(`calls view call_1 --project ${PROJECT} --json`) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + `/platform/calls/call_1?projectId=${PROJECT}`, + ) + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual( + detail, + ) + await server.close() + server = await startHttpServer({body: {data: {call: {callId: 'call_1'}}}}) + const invalid = await run('calls view call_1 --json') + expect(invalid.error).toBeDefined() + expect(invalid.stderr).toContain('invalid call detail') + await server.close() + server = await startHttpServer({ + body: {data: {...detail, history: {events: [null], truncated: false}}}, + }) + const malformedHistory = await run('calls view call_1') + expect(malformedHistory.error).toBeDefined() + expect(malformedHistory.stderr).toContain('invalid call detail') + }, + ) + test('renders bounded history without terminal control characters', () => { + expect( + renderCallDetail({ + call: { + callId: 'call_1', + sessionId: '\u001b[31msupport', + state: 'ended', + live: false, + }, + history: { + events: [{eventId: 'e', type: 'call.ended', occurredAt: 'now'}], + truncated: true, + }, + }), + ).not.toContain('\u001b') + }) +}) diff --git a/test/commands/calls-social.test.ts b/test/commands/calls-social.test.ts new file mode 100644 index 00000000..5976daab --- /dev/null +++ b/test/commands/calls-social.test.ts @@ -0,0 +1,75 @@ +import {describe, expect, test, vi} from 'vitest' +import type {MessagingClient} from '@polymorfa/sdk' +import {reactToCall, setCallHand} from '../../src/services/calls.js' +const credential = {type: 'apiKey' as const, value: 'unused'} +const response = { + data: {success: true}, + metadata: {status: 202, attempts: 1, headers: {}}, +} + +describe('call reaction and hand commands', () => { + test('validates connection ownership input and dispatches a single typed SDK request', async () => { + const sendReaction = vi.fn(() => Promise.resolve(response)) + const setHandRaised = vi.fn(() => Promise.resolve(response)) + const client = { + voip: {sendReaction, setHandRaised}, + } as unknown as MessagingClient + await reactToCall(client, credential, 'call-1', { + connectionId: 'connection-1', + emoji: '๐Ÿ‘', + participant: 'agent', + }) + await reactToCall(client, credential, 'call-1', { + connectionId: 'connection-1', + emoji: '', + }) + await setCallHand(client, credential, 'call-1', { + connectionId: 'connection-1', + raised: false, + }) + expect(sendReaction).toHaveBeenNthCalledWith(1, 'call-1', { + connectionId: 'connection-1', + emoji: '๐Ÿ‘', + participant: 'agent', + }) + expect(setHandRaised).toHaveBeenCalledWith('call-1', { + connectionId: 'connection-1', + raised: false, + }) + await expect( + reactToCall(client, credential, 'call-1', { + connectionId: 'connection-1', + emoji: 'plain text', + }), + ).rejects.toThrow('Reaction must') + await expect( + setCallHand(client, {type: 'clientToken', value: 'unused'}, 'call-1', { + connectionId: 'connection-1', + raised: true, + participant: 'other', + }), + ).rejects.toThrow('Client tokens') + expect(sendReaction).toHaveBeenCalledTimes(2) + }) + test('refuses an older published SDK explicitly and never retries uncertain sends', async () => { + await expect( + reactToCall({voip: {}} as MessagingClient, credential, 'call-1', { + connectionId: 'connection-1', + emoji: '๐Ÿ‘', + }), + ).rejects.toThrow('published SDK version') + const sendReaction = vi.fn(() => + Promise.reject(new Error('lost after send')), + ) + const client = { + voip: {sendReaction, setHandRaised: vi.fn()}, + } as unknown as MessagingClient + await expect( + reactToCall(client, credential, 'call-1', { + connectionId: 'connection-1', + emoji: '๐Ÿ‘', + }), + ).rejects.toThrow() + expect(sendReaction).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 3fc4dc96..f9bb6e24 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -1,4 +1,5 @@ import {runCommand} from '@oclif/test' +import {MessagingClient} from '@polymorfa/sdk' import {afterEach, describe, expect, test} from 'vitest' import { @@ -31,6 +32,160 @@ describe('unified calls commands', () => { server = undefined }) + test.skipIf( + !( + 'ringParticipant' in + new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip + ), + )('places one group request and rings an existing participant', async () => { + server = await startHttpSequenceServer([ + { + body: { + success: true, + data: {callId: 'group_1', session: 'support', video: false}, + }, + status: 201, + }, + {body: {success: true}, status: 202}, + ]) + const group = await apiKey(() => + runCommand( + 'calls place-group --to +15550100 --to +15550101 --session support --json', + ), + ) + expect(group.error).toBeUndefined() + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + session: 'support', + participants: ['+15550100', '+15550101'], + }) + const ring = await apiKey(() => + runCommand('calls ring group_1 +15550100 --json'), + ) + expect(ring.error).toBeUndefined() + expect(server.requests[1]?.url).toBe( + '/messaging/voip/calls/group_1/participants/ring', + ) + expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + to: '+15550100', + }) + expect(server.requests).toHaveLength(2) + }) + + test.skipIf( + 'ringParticipant' in + new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip, + )( + 'refuses group controls with an older installed SDK before transport', + async () => { + server = await startHttpServer({body: {success: true}}) + for (const command of [ + 'calls place-group --to +15550100 --to +15550101 --session support --json', + 'calls place-group --group-id 9007199254740996 --session support --json', + 'calls ring call_1 +15550100 --json', + 'calls react call_1 --connection-id connection-1 --emoji ๐Ÿ‘ --json', + 'calls hand call_1 --connection-id connection-1 --raised --json', + ]) { + const response = await apiKey(() => runCommand(command)) + expect(response.error).toBeDefined() + expect(`${response.stderr} ${response.error}`).toContain( + 'published SDK version', + ) + } + expect(server.requests).toHaveLength(0) + }, + ) + test.skipIf( + !( + 'ringParticipant' in + new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip + ), + )('places a stored group by public ID', async () => { + server = await startHttpServer({ + body: { + success: true, + data: {callId: 'stored-group', session: 'support', video: false}, + }, + status: 201, + }) + const result = await apiKey(() => + runCommand( + 'calls place-group --group-id 9007199254740996 --session support --json', + ), + ) + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + session: 'support', + groupId: '9007199254740996', + }) + expect(server.requests).toHaveLength(1) + }) + + test('rejects invalid group IDs and mixed destinations before transport', async () => { + server = await startHttpServer({body: {success: true}}) + for (const args of [ + '--group-id 123@g.us', + '--group-id 9007199254740996 --to +15550100', + '', + ]) { + const denied = await apiKey(() => + runCommand(`calls place-group ${args} --session support --json`), + ) + expect(exitCode(denied.error)).toBe(2) + } + expect(server.requests).toHaveLength(0) + }) + + test('rejects too few or duplicate group participants before transport', async () => { + server = await startHttpServer({body: {success: true}}) + for (const recipients of [ + '--to +15550100', + '--to +15550100 --to +15550100', + ]) { + const response = await apiKey(() => + runCommand(`calls place-group ${recipients} --session support --json`), + ) + expect(exitCode(response.error)).toBe(2) + } + expect(server.requests).toHaveLength(0) + }) + + test.skipIf( + !( + 'sendReaction' in + new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip + ), + )( + 'sends call reactions and hand state through a locally built SDK', + async () => { + server = await startHttpServer({body: {success: true}, status: 202}) + const reaction = await apiKey(() => + runCommand( + 'calls react call_1 --connection-id connection-1 --emoji ๐Ÿ‘ --participant agent --json', + ), + ) + expect(reaction.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/messaging/voip/calls/call_1/reaction', + ) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + connectionId: 'connection-1', + emoji: '๐Ÿ‘', + participant: 'agent', + }) + const hand = await apiKey(() => + runCommand( + 'calls hand call_1 --connection-id connection-1 --raised --json', + ), + ) + expect(hand.error).toBeUndefined() + expect(server.requests[1]?.url).toBe('/messaging/voip/calls/call_1/hand') + expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + connectionId: 'connection-1', + raised: true, + }) + }, + ) + test('places a call from a named session with a claim and participant', async () => { server = await startHttpServer({ body: { diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index 2390e32e..95c75fb1 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -94,6 +94,7 @@ describe('sip-trunk commands', () => { `${TRUNK_ID}\tHead office PBX\tboth\tenabled\tsips:pbx.example.com\n`, ) expect(viewed.stdout).toContain('Password stored\tyes') + expect(viewed.stdout).toContain('From user\tcaller identity (automatic)') expect(viewed.stdout).toContain('Calling session\tnot set (calls refused)') expect(viewed.stdout).toContain('Allowed destinations\tany') expect(viewed.stdout).toContain('Revision\t3')