From 1c81fcaf48d30e5b7e093ac2954b3a1217b2b1ef Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:10:33 +0200 Subject: [PATCH 01/11] feat(calls): add group placement and re-ring commands --- README.md | 14 +++++++++ src/commands/calls/place-group.ts | 41 ++++++++++++++++++++++++++ src/commands/calls/ring.ts | 30 +++++++++++++++++++ src/services/calls.ts | 30 +++++++++++++++++-- test/commands/calls.test.ts | 48 +++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 src/commands/calls/place-group.ts create mode 100644 src/commands/calls/ring.ts diff --git a/README.md b/README.md index 3845eaa7..c0b22685 100644 --- a/README.md +++ b/README.md @@ -704,6 +704,20 @@ 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 ring call_123 +15550101 +``` + +Group placement accepts 2 to 31 distinct recipients and checks every recipient's +calling permissions. 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. diff --git a/src/commands/calls/place-group.ts b/src/commands/calls/place-group.ts new file mode 100644 index 00000000..45072e14 --- /dev/null +++ b/src/commands/calls/place-group.ts @@ -0,0 +1,41 @@ +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 ad-hoc WhatsApp group call 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', + ] + static override flags = { + ...CallsPlace.flags, + to: Flags.string({ + description: + 'E.164 phone number or public user ID. Repeat for each recipient.', + multiple: true, + required: true, + }), + } + static override summary = 'Place an ad-hoc 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, + 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/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/services/calls.ts b/src/services/calls.ts index 68ade6ef..91a76453 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -138,7 +138,7 @@ export interface PlaceCallInput { readonly exclusive?: boolean | undefined readonly participant?: string | undefined readonly session?: string | undefined - readonly to: string + readonly to: string | readonly string[] readonly video?: boolean | undefined } @@ -147,7 +147,18 @@ export async function placeCall( credential: CallsCredential, input: PlaceCallInput, ): Promise> { - const to = validateCallTarget(input.to) + const to = + typeof input.to === 'string' + ? validateCallTarget(input.to) + : input.to.map((target) => validateCallTarget(target)) + if ( + 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, @@ -156,7 +167,7 @@ export async function placeCall( const participant = validateParticipant(credential, input.participant) const response = await callRequest(() => client.voip.place({ - to, + ...(typeof to === 'string' ? {to} : {participants: to}), ...(session === undefined ? {} : {session}), ...(input.video === undefined ? {} : {video: input.video}), ...(input.exclusive === undefined ? {} : {exclusive: input.exclusive}), @@ -247,6 +258,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(() => + client.voip.ringParticipant(callId, {to}), + ) + return result(response.data, response.metadata) +} + export async function viewCallSettings( client: MessagingClient, session: string, diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 3fc4dc96..8ea666d1 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -31,6 +31,54 @@ describe('unified calls commands', () => { server = undefined }) + test('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('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('places a call from a named session with a claim and participant', async () => { server = await startHttpServer({ body: { From 61aa564868cdd385c7ecf0e18ba6b8532aa4ad74 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:07:07 +0200 Subject: [PATCH 02/11] Add guarded connection mute option for Calls media CLI --- README.md | 4 +- src/commands/calls/media.ts | 8 +++ src/services/call-media-controls.ts | 32 ++++++++++ src/services/calls.ts | 71 +++++++++++++---------- test/commands/call-media-controls.test.ts | 23 ++++++++ 5 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 src/services/call-media-controls.ts create mode 100644 test/commands/call-media-controls.test.ts diff --git a/README.md b/README.md index c0b22685..8a884d9e 100644 --- a/README.md +++ b/README.md @@ -816,7 +816,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: 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/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 91a76453..18cfebca 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 { @@ -938,6 +942,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 @@ -998,6 +1003,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 @@ -1095,36 +1101,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))) }) } 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) + }) +}) From 6cf2aee19fb9c75595cc5189a7a55da602eb53f5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:06:47 +0200 Subject: [PATCH 03/11] feat(calls): add reaction and hand commands with SDK release guard --- README.md | 14 ++++++ src/commands/calls/hand.ts | 49 ++++++++++++++++++ src/commands/calls/react.ts | 55 +++++++++++++++++++++ src/services/calls.ts | 79 ++++++++++++++++++++++++++++++ test/commands/calls-social.test.ts | 75 ++++++++++++++++++++++++++++ test/commands/calls.test.ts | 38 ++++++++++++++ 6 files changed, 310 insertions(+) create mode 100644 src/commands/calls/hand.ts create mode 100644 src/commands/calls/react.ts create mode 100644 test/commands/calls-social.test.ts diff --git a/README.md b/README.md index 8a884d9e..df6fbfc6 100644 --- a/README.md +++ b/README.md @@ -2000,3 +2000,17 @@ 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. 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/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/services/calls.ts b/src/services/calls.ts index 18cfebca..bf6733cb 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -1340,3 +1340,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/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 8ea666d1..8670c2af 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 { @@ -79,6 +80,43 @@ describe('unified calls commands', () => { 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: { From 627e0f23b102c6b5508e730ebbdf8b836db4852f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:16:27 +0200 Subject: [PATCH 04/11] fix(sip): describe automatic caller identity in trunk output --- README.md | 4 ++++ src/services/sip-trunks.ts | 2 +- test/commands/sip-trunks.test.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index df6fbfc6..a4c6d883 100644 --- a/README.md +++ b/README.md @@ -959,6 +959,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. 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/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') From e7bbaa57408c19920bc2ce6f6cf57754f0d893e4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:28:29 +0200 Subject: [PATCH 05/11] Guard group Calls commands until the matching SDK is published --- README.md | 5 ++++ src/services/calls.ts | 49 +++++++++++++++++++++++++++++++------ test/commands/calls.test.ts | 30 ++++++++++++++++++++++- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a4c6d883..b9051ce3 100644 --- a/README.md +++ b/README.md @@ -2018,3 +2018,8 @@ These source commands require the SDK release containing `sendReaction` and 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. diff --git a/src/services/calls.ts b/src/services/calls.ts index bf6733cb..5f6dd4bd 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -146,6 +146,37 @@ export interface PlaceCallInput { readonly video?: boolean | undefined } +interface GroupCallsResource { + place(input: { + participants: readonly string[] + 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, @@ -169,14 +200,16 @@ export async function placeCall( '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({ - ...(typeof to === 'string' ? {to} : {participants: to}), - ...(session === undefined ? {} : {session}), - ...(input.video === undefined ? {} : {video: input.video}), - ...(input.exclusive === undefined ? {} : {exclusive: input.exclusive}), - ...(participant === undefined ? {} : {participant}), - }), + typeof to === 'string' + ? client.voip.place({to, ...options}) + : groupResource(client.voip).place({participants: to, ...options}), ) return result(response.data.data, response.metadata) } @@ -270,7 +303,7 @@ export async function ringCallParticipant( validateCallId(callId) const to = validateCallTarget(target, 'target') const response = await callRequest(() => - client.voip.ringParticipant(callId, {to}), + groupResource(client.voip).ringParticipant(callId, {to}), ) return result(response.data, response.metadata) } diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 8670c2af..e514787e 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -32,7 +32,12 @@ describe('unified calls commands', () => { server = undefined }) - test('places one group request and rings an existing participant', async () => { + 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: { @@ -66,6 +71,29 @@ describe('unified calls commands', () => { 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 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('rejects too few or duplicate group participants before transport', async () => { server = await startHttpServer({body: {success: true}}) for (const recipients of [ From f8b1d1d005300f0e73e8a30421931e8ccd4b646a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:30:42 +0200 Subject: [PATCH 06/11] Add bounded call record listing and guarded detail commands --- README.md | 20 ++++ src/commands/calls/list.ts | 51 ++++++++++ src/commands/calls/view.ts | 32 +++++++ src/services/call-analytics.ts | 110 +++++++++++++++++++++ test/commands/call-records.test.ts | 148 +++++++++++++++++++++++++++++ 5 files changed, 361 insertions(+) create mode 100644 src/commands/calls/list.ts create mode 100644 src/commands/calls/view.ts create mode 100644 test/commands/call-records.test.ts diff --git a/README.md b/README.md index b9051ce3..be5ba14d 100644 --- a/README.md +++ b/README.md @@ -2023,3 +2023,23 @@ 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/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/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..2b1a6b71 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,52 @@ 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) || + 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 +542,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/test/commands/call-records.test.ts b/test/commands/call-records.test.ts new file mode 100644 index 00000000..3bcb67d0 --- /dev/null +++ b/test/commands/call-records.test.ts @@ -0,0 +1,148 @@ +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).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') + }, + ) + 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') + }) +}) From 3d9bd110cfb324b0cc1b51e9980ab4d72036c78c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:36:45 +0200 Subject: [PATCH 07/11] Reject malformed call history before rendering CLI output --- src/services/call-analytics.ts | 8 ++++++++ test/commands/call-records.test.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/services/call-analytics.ts b/src/services/call-analytics.ts index 2b1a6b71..7f7d5ebf 100644 --- a/src/services/call-analytics.ts +++ b/src/services/call-analytics.ts @@ -154,6 +154,14 @@ export class CallAnalyticsApi { typeof data.call.state !== 'string' || typeof data.call.live !== 'boolean' || !Array.isArray(data.history?.events) || + !data.history.events.every( + (event) => + event !== null && + typeof event === 'object' && + typeof event.eventId === 'string' && + typeof event.type === 'string' && + typeof event.occurredAt === 'string', + ) || typeof data.history.truncated !== 'boolean' ) { throw new PolymorfaServerError( diff --git a/test/commands/call-records.test.ts b/test/commands/call-records.test.ts index 3bcb67d0..6308e3c0 100644 --- a/test/commands/call-records.test.ts +++ b/test/commands/call-records.test.ts @@ -127,6 +127,13 @@ describe('stored call record commands', () => { 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', () => { From 1ae401841c5ed25758741ab0790f00da15cc6b8d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:32:50 +0200 Subject: [PATCH 08/11] feat(calls): place group calls by public group ID --- README.md | 6 ++++- src/commands/calls/place-group.ts | 13 +++++++--- src/services/calls.ts | 43 ++++++++++++++++++++++--------- test/commands/calls.test.ts | 41 +++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index be5ba14d..a21db1a8 100644 --- a/README.md +++ b/README.md @@ -710,11 +710,15 @@ 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. Group calls require a Linked Device Number. `calls ring` +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. diff --git a/src/commands/calls/place-group.ts b/src/commands/calls/place-group.ts index 45072e14..6dae6da8 100644 --- a/src/commands/calls/place-group.ts +++ b/src/commands/calls/place-group.ts @@ -7,20 +7,26 @@ import CallsPlace from './place.js' export default class CallsPlaceGroup extends BaseCommand { static override description = - 'Place one ad-hoc WhatsApp group call 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.' + '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, - required: true, + exclusive: ['group-id'], }), } - static override summary = 'Place an ad-hoc group call.' + static override summary = 'Place a WhatsApp group call.' async run(): Promise { const {flags} = await this.parse(CallsPlaceGroup) @@ -30,6 +36,7 @@ export default class CallsPlaceGroup extends BaseCommand { environment.credential, { to: flags.to, + groupId: flags['group-id'], session: flags.session, video: flags.video, exclusive: flags.exclusive, diff --git a/src/services/calls.ts b/src/services/calls.ts index 5f6dd4bd..c6f5bfb2 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -142,18 +142,23 @@ export interface PlaceCallInput { readonly exclusive?: boolean | undefined readonly participant?: string | undefined readonly session?: string | undefined - readonly to: string | readonly string[] + readonly to?: string | readonly string[] | undefined + readonly groupId?: string | undefined readonly video?: boolean | undefined } interface GroupCallsResource { - place(input: { - participants: readonly string[] - session?: string - video?: boolean - exclusive?: boolean - participant?: string - }): ReturnType + 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}, @@ -182,11 +187,23 @@ export async function placeCall( credential: CallsCredential, input: PlaceCallInput, ): Promise> { + 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)) + : input.to?.map((target) => validateCallTarget(target)) if ( + to !== undefined && typeof to !== 'string' && (to.length < 2 || to.length > 31 || new Set(to).size !== to.length) ) { @@ -207,9 +224,11 @@ export async function placeCall( ...(participant === undefined ? {} : {participant}), } const response = await callRequest(() => - typeof to === 'string' - ? client.voip.place({to, ...options}) - : groupResource(client.voip).place({participants: to, ...options}), + 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) } diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index e514787e..f9bb6e24 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -80,6 +80,7 @@ describe('unified calls commands', () => { 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', @@ -93,6 +94,46 @@ describe('unified calls commands', () => { 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}}) From 007afee6ba1f420855b2b73aa85433d9c70f86c6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:45:30 +0200 Subject: [PATCH 09/11] feat(calls): add private call-link CLI commands --- README.md | 18 +++++ package.json | 3 + src/commands/calls/links/create.ts | 34 +++++++++ src/commands/calls/links/preview.ts | 45 +++++++++++ src/services/call-links.ts | 57 ++++++++++++++ test/commands/calls.test.ts | 112 ++++++++++++++++++++++++++++ 6 files changed, 269 insertions(+) create mode 100644 src/commands/calls/links/create.ts create mode 100644 src/commands/calls/links/preview.ts create mode 100644 src/services/call-links.ts diff --git a/README.md b/README.md index 3ea63546..65c4c761 100644 --- a/README.md +++ b/README.md @@ -675,6 +675,24 @@ pagination. Block and unblock are reversible actions and accept ## Calls +### Call links in this source revision + +The source adds `calls links create` and `calls links preview`. They require +this contract's deployed API and a published SDK with call-link methods; local +builds do not establish availability. + +```sh +polymorfa calls links create --session support --json +polymorfa calls links preview --session support --json < call-link-token.txt +``` + +Use a server API key with `sessions:manage` and a Linked Device Number with +calling enabled. Client tokens and Cloud API-only Numbers are refused. Add +`--video` for a video link. Create prints a private URL; preview reads the token +from standard input to keep it out of command arguments and shell history. +Neither command joins a call or changes approval settings. Requests are sent +once. Do not retry an unknown creation outcome; there is no idempotency receipt. + The `calls` commands place, answer, join, decline, leave, and end calls, invite participants, follow a session's calls, and carry call audio. `calls stats` and `calls export` report on past calls; see diff --git a/package.json b/package.json index 03953d32..3b489b12 100644 --- a/package.json +++ b/package.json @@ -293,6 +293,9 @@ }, "function:invocation": { "description": "Invoke Functions once and inspect metadata receipts." + }, + "calls:links": { + "description": "Create and preview private WhatsApp call links with a server API key." } } }, diff --git a/src/commands/calls/links/create.ts b/src/commands/calls/links/create.ts new file mode 100644 index 00000000..4fd8c601 --- /dev/null +++ b/src/commands/calls/links/create.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {createMessagingClient} from '../../../sdk/client.js' +import {createCallLink} from '../../../services/call-links.js' + +export default class CallsLinksCreate extends BaseCommand { + static override summary = 'Create a private WhatsApp call link.' + static override description = + 'Requires a server API key with sessions:manage and a Linked Device Number with calling enabled. Prints the private link without placing or joining a call. This command does not retry an unknown creation outcome.' + static override flags = { + session: Flags.string({ + required: true, + description: 'Linked Device session that creates the link.', + }), + video: Flags.boolean({ + default: false, + description: 'Create a video link instead of a voice link.', + }), + } + static override examples = [ + '<%= config.bin %> calls links create --session support --json', + ] + async run(): Promise { + const {flags} = await this.parse(CallsLinksCreate) + const environment = await this.messagingEnvironment(flags) + const result = await createCallLink( + createMessagingClient(environment), + environment.credential, + flags.session, + flags.video, + ) + this.writeValue(result, result.data.url, flags) + } +} diff --git a/src/commands/calls/links/preview.ts b/src/commands/calls/links/preview.ts new file mode 100644 index 00000000..6995b1a7 --- /dev/null +++ b/src/commands/calls/links/preview.ts @@ -0,0 +1,45 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {createMessagingClient} from '../../../sdk/client.js' +import {previewCallLink} from '../../../services/call-links.js' + +export default class CallsLinksPreview extends BaseCommand { + static override summary = + 'Preview a WhatsApp call link from a token on standard input.' + static override description = + 'Requires a server API key with sessions:manage and a Linked Device Number with calling enabled. Read the private token from standard input; use --video for a video link. Does not join the call or change approval settings.' + static override flags = { + session: Flags.string({ + required: true, + description: 'Linked Device session that previews the link.', + }), + video: Flags.boolean({ + default: false, + description: 'The token belongs to a video link.', + }), + } + static override examples = [ + '<%= config.bin %> calls links preview --session support --json < call-link-token.txt', + ] + async run(): Promise { + const {flags} = await this.parse(CallsLinksPreview) + const environment = await this.messagingEnvironment(flags) + const result = await previewCallLink( + createMessagingClient(environment), + environment.credential, + flags.session, + flags.video, + ) + const creator = result.data.creator + this.writeValue( + result, + [ + `Creator\t${creator.id}`, + `Approval required\t${result.data.approvalRequired}`, + `Administrator\t${result.data.isAdmin}`, + `Video\t${result.data.video}`, + ].join('\n'), + flags, + ) + } +} diff --git a/src/services/call-links.ts b/src/services/call-links.ts new file mode 100644 index 00000000..b89ae3c1 --- /dev/null +++ b/src/services/call-links.ts @@ -0,0 +1,57 @@ +import {PolymorfaConfigurationError, type MessagingClient} from '@polymorfa/sdk' +import type {CallsCredential} from './calls.js' +import {readSecretFromStdin} from '../input/stdin.js' + +export function assertCallLinkCredential(credential: CallsCredential): void { + if (credential.type === 'clientToken') + throw new PolymorfaConfigurationError( + 'Call links require a server API key; browser client tokens are refused.', + 'credential', + ) +} + +export async function createCallLink( + client: MessagingClient, + credential: CallsCredential, + session: string, + video: boolean, +) { + assertCallLinkCredential(credential) + const response = await client.voip.createCallLink({session, video}) + return { + data: response.data.data, + metadata: { + status: response.metadata.status, + attempts: response.metadata.attempts, + ...(response.metadata.requestId + ? {requestId: response.metadata.requestId} + : {}), + }, + } +} + +export async function previewCallLink( + client: MessagingClient, + credential: CallsCredential, + session: string, + video: boolean, +) { + assertCallLinkCredential(credential) + if (process.stdin.isTTY) + throw new PolymorfaConfigurationError( + 'Pipe the private call-link token through standard input.', + 'stdin', + ) + const token = await readSecretFromStdin() + const response = await client.voip.previewCallLink({session, token, video}) + return { + data: response.data.data, + metadata: { + status: response.metadata.status, + attempts: response.metadata.attempts, + ...(response.metadata.requestId + ? {requestId: response.metadata.requestId} + : {}), + }, + } +} diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index f9bb6e24..b5c15683 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -1,5 +1,6 @@ import {runCommand} from '@oclif/test' import {MessagingClient} from '@polymorfa/sdk' +import {Readable} from 'node:stream' import {afterEach, describe, expect, test} from 'vitest' import { @@ -32,6 +33,101 @@ describe('unified calls commands', () => { server = undefined }) + test('creates and previews private call links without a token argument', async () => { + const token = 'private_link_test_token' + server = await startHttpSequenceServer([ + { + status: 201, + body: { + success: true, + data: { + session: 'support', + token, + url: `https://call.whatsapp.com/voice/${token}`, + video: false, + }, + }, + }, + { + body: { + success: true, + data: { + session: 'support', + video: false, + creator: {id: '9007199254740993'}, + approvalRequired: true, + isAdmin: false, + }, + }, + }, + ]) + const created = await apiKey(() => + runCommand('calls links create --session support --json'), + ) + expect(created.error).toBeUndefined() + expect((JSON.parse(created.stdout) as {data: {url: string}}).data.url).toBe( + `https://call.whatsapp.com/voice/${token}`, + ) + const previewed = await withCallLinkStdin(token, () => + apiKey(() => runCommand('calls links preview --session support --json')), + ) + expect(previewed.error).toBeUndefined() + expect(previewed.stdout).not.toContain(token) + expect( + (JSON.parse(previewed.stdout) as {data: unknown}).data, + ).toMatchObject({ + approvalRequired: true, + isAdmin: false, + }) + expect( + server.requests.map(({method, url, body}) => ({ + method, + url, + body: JSON.parse(body) as unknown, + })), + ).toEqual([ + { + method: 'POST', + url: '/messaging/voip/call-links', + body: {session: 'support', video: false}, + }, + { + method: 'POST', + url: '/messaging/voip/call-links/preview', + body: {session: 'support', token, video: false}, + }, + ]) + }) + + test('refuses client tokens, missing sessions and invalid link tokens before transport', async () => { + server = await startHttpServer({body: {success: true}}) + const client = await clientToken(() => + runCommand('calls links create --session support --json'), + ) + const missing = await apiKey(() => runCommand('calls links create --json')) + const invalid = await withCallLinkStdin('private/bad/token', () => + apiKey(() => runCommand('calls links preview --session support --json')), + ) + for (const response of [client, missing, invalid]) + expect(exitCode(response.error)).toBe(2) + expect(invalid.stderr).not.toContain('private/bad/token') + expect(server.requests).toHaveLength(0) + }) + + test('does not retry a call-link creation failure', async () => { + server = await startHttpServer({ + status: 502, + body: { + error: {code: 'upstream_failure', message: 'Call provider failed'}, + }, + }) + const response = await apiKey(() => + runCommand('calls links create --session support --json'), + ) + expect(response.error).toBeDefined() + expect(server.requests).toHaveLength(1) + }) + test.skipIf( !( 'ringParticipant' in @@ -1129,6 +1225,22 @@ describe('unified calls commands', () => { } }) +async function withCallLinkStdin( + value: string, + run: () => Promise, +): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin') + Object.defineProperty(process, 'stdin', { + configurable: true, + value: Readable.from([value]), + }) + try { + return await run() + } finally { + if (descriptor) Object.defineProperty(process, 'stdin', descriptor) + } +} + function apiError( status: number, code: string, From b7b48257f6c6a9326734988b38d71dc86917703f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:51:54 +0200 Subject: [PATCH 10/11] fix(calls): type-safe call records CLI formatting --- src/services/call-analytics.ts | 5 ++++- test/commands/call-records.test.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/services/call-analytics.ts b/src/services/call-analytics.ts index 7f7d5ebf..9f223232 100644 --- a/src/services/call-analytics.ts +++ b/src/services/call-analytics.ts @@ -155,9 +155,12 @@ export class CallAnalyticsApi { typeof data.call.live !== 'boolean' || !Array.isArray(data.history?.events) || !data.history.events.every( - (event) => + (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', diff --git a/test/commands/call-records.test.ts b/test/commands/call-records.test.ts index 6308e3c0..c19fd812 100644 --- a/test/commands/call-records.test.ts +++ b/test/commands/call-records.test.ts @@ -121,7 +121,9 @@ describe('stored call record commands', () => { expect(server.requests[0]?.url).toBe( `/platform/calls/call_1?projectId=${PROJECT}`, ) - expect(JSON.parse(result.stdout).data).toEqual(detail) + 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') From f560bb6239adbae9240a4d37b5cfb4913a8d456c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:51:54 +0200 Subject: [PATCH 11/11] Revert "feat(calls): add private call-link CLI commands" This reverts commit 007afee6ba1f420855b2b73aa85433d9c70f86c6. --- README.md | 18 ----- package.json | 3 - src/commands/calls/links/create.ts | 34 --------- src/commands/calls/links/preview.ts | 45 ----------- src/services/call-links.ts | 57 -------------- test/commands/calls.test.ts | 112 ---------------------------- 6 files changed, 269 deletions(-) delete mode 100644 src/commands/calls/links/create.ts delete mode 100644 src/commands/calls/links/preview.ts delete mode 100644 src/services/call-links.ts diff --git a/README.md b/README.md index 65c4c761..3ea63546 100644 --- a/README.md +++ b/README.md @@ -675,24 +675,6 @@ pagination. Block and unblock are reversible actions and accept ## Calls -### Call links in this source revision - -The source adds `calls links create` and `calls links preview`. They require -this contract's deployed API and a published SDK with call-link methods; local -builds do not establish availability. - -```sh -polymorfa calls links create --session support --json -polymorfa calls links preview --session support --json < call-link-token.txt -``` - -Use a server API key with `sessions:manage` and a Linked Device Number with -calling enabled. Client tokens and Cloud API-only Numbers are refused. Add -`--video` for a video link. Create prints a private URL; preview reads the token -from standard input to keep it out of command arguments and shell history. -Neither command joins a call or changes approval settings. Requests are sent -once. Do not retry an unknown creation outcome; there is no idempotency receipt. - The `calls` commands place, answer, join, decline, leave, and end calls, invite participants, follow a session's calls, and carry call audio. `calls stats` and `calls export` report on past calls; see diff --git a/package.json b/package.json index 3b489b12..03953d32 100644 --- a/package.json +++ b/package.json @@ -293,9 +293,6 @@ }, "function:invocation": { "description": "Invoke Functions once and inspect metadata receipts." - }, - "calls:links": { - "description": "Create and preview private WhatsApp call links with a server API key." } } }, diff --git a/src/commands/calls/links/create.ts b/src/commands/calls/links/create.ts deleted file mode 100644 index 4fd8c601..00000000 --- a/src/commands/calls/links/create.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {Flags} from '@oclif/core' -import {BaseCommand} from '../../../base-command.js' -import {createMessagingClient} from '../../../sdk/client.js' -import {createCallLink} from '../../../services/call-links.js' - -export default class CallsLinksCreate extends BaseCommand { - static override summary = 'Create a private WhatsApp call link.' - static override description = - 'Requires a server API key with sessions:manage and a Linked Device Number with calling enabled. Prints the private link without placing or joining a call. This command does not retry an unknown creation outcome.' - static override flags = { - session: Flags.string({ - required: true, - description: 'Linked Device session that creates the link.', - }), - video: Flags.boolean({ - default: false, - description: 'Create a video link instead of a voice link.', - }), - } - static override examples = [ - '<%= config.bin %> calls links create --session support --json', - ] - async run(): Promise { - const {flags} = await this.parse(CallsLinksCreate) - const environment = await this.messagingEnvironment(flags) - const result = await createCallLink( - createMessagingClient(environment), - environment.credential, - flags.session, - flags.video, - ) - this.writeValue(result, result.data.url, flags) - } -} diff --git a/src/commands/calls/links/preview.ts b/src/commands/calls/links/preview.ts deleted file mode 100644 index 6995b1a7..00000000 --- a/src/commands/calls/links/preview.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {Flags} from '@oclif/core' -import {BaseCommand} from '../../../base-command.js' -import {createMessagingClient} from '../../../sdk/client.js' -import {previewCallLink} from '../../../services/call-links.js' - -export default class CallsLinksPreview extends BaseCommand { - static override summary = - 'Preview a WhatsApp call link from a token on standard input.' - static override description = - 'Requires a server API key with sessions:manage and a Linked Device Number with calling enabled. Read the private token from standard input; use --video for a video link. Does not join the call or change approval settings.' - static override flags = { - session: Flags.string({ - required: true, - description: 'Linked Device session that previews the link.', - }), - video: Flags.boolean({ - default: false, - description: 'The token belongs to a video link.', - }), - } - static override examples = [ - '<%= config.bin %> calls links preview --session support --json < call-link-token.txt', - ] - async run(): Promise { - const {flags} = await this.parse(CallsLinksPreview) - const environment = await this.messagingEnvironment(flags) - const result = await previewCallLink( - createMessagingClient(environment), - environment.credential, - flags.session, - flags.video, - ) - const creator = result.data.creator - this.writeValue( - result, - [ - `Creator\t${creator.id}`, - `Approval required\t${result.data.approvalRequired}`, - `Administrator\t${result.data.isAdmin}`, - `Video\t${result.data.video}`, - ].join('\n'), - flags, - ) - } -} diff --git a/src/services/call-links.ts b/src/services/call-links.ts deleted file mode 100644 index b89ae3c1..00000000 --- a/src/services/call-links.ts +++ /dev/null @@ -1,57 +0,0 @@ -import {PolymorfaConfigurationError, type MessagingClient} from '@polymorfa/sdk' -import type {CallsCredential} from './calls.js' -import {readSecretFromStdin} from '../input/stdin.js' - -export function assertCallLinkCredential(credential: CallsCredential): void { - if (credential.type === 'clientToken') - throw new PolymorfaConfigurationError( - 'Call links require a server API key; browser client tokens are refused.', - 'credential', - ) -} - -export async function createCallLink( - client: MessagingClient, - credential: CallsCredential, - session: string, - video: boolean, -) { - assertCallLinkCredential(credential) - const response = await client.voip.createCallLink({session, video}) - return { - data: response.data.data, - metadata: { - status: response.metadata.status, - attempts: response.metadata.attempts, - ...(response.metadata.requestId - ? {requestId: response.metadata.requestId} - : {}), - }, - } -} - -export async function previewCallLink( - client: MessagingClient, - credential: CallsCredential, - session: string, - video: boolean, -) { - assertCallLinkCredential(credential) - if (process.stdin.isTTY) - throw new PolymorfaConfigurationError( - 'Pipe the private call-link token through standard input.', - 'stdin', - ) - const token = await readSecretFromStdin() - const response = await client.voip.previewCallLink({session, token, video}) - return { - data: response.data.data, - metadata: { - status: response.metadata.status, - attempts: response.metadata.attempts, - ...(response.metadata.requestId - ? {requestId: response.metadata.requestId} - : {}), - }, - } -} diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index b5c15683..f9bb6e24 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -1,6 +1,5 @@ import {runCommand} from '@oclif/test' import {MessagingClient} from '@polymorfa/sdk' -import {Readable} from 'node:stream' import {afterEach, describe, expect, test} from 'vitest' import { @@ -33,101 +32,6 @@ describe('unified calls commands', () => { server = undefined }) - test('creates and previews private call links without a token argument', async () => { - const token = 'private_link_test_token' - server = await startHttpSequenceServer([ - { - status: 201, - body: { - success: true, - data: { - session: 'support', - token, - url: `https://call.whatsapp.com/voice/${token}`, - video: false, - }, - }, - }, - { - body: { - success: true, - data: { - session: 'support', - video: false, - creator: {id: '9007199254740993'}, - approvalRequired: true, - isAdmin: false, - }, - }, - }, - ]) - const created = await apiKey(() => - runCommand('calls links create --session support --json'), - ) - expect(created.error).toBeUndefined() - expect((JSON.parse(created.stdout) as {data: {url: string}}).data.url).toBe( - `https://call.whatsapp.com/voice/${token}`, - ) - const previewed = await withCallLinkStdin(token, () => - apiKey(() => runCommand('calls links preview --session support --json')), - ) - expect(previewed.error).toBeUndefined() - expect(previewed.stdout).not.toContain(token) - expect( - (JSON.parse(previewed.stdout) as {data: unknown}).data, - ).toMatchObject({ - approvalRequired: true, - isAdmin: false, - }) - expect( - server.requests.map(({method, url, body}) => ({ - method, - url, - body: JSON.parse(body) as unknown, - })), - ).toEqual([ - { - method: 'POST', - url: '/messaging/voip/call-links', - body: {session: 'support', video: false}, - }, - { - method: 'POST', - url: '/messaging/voip/call-links/preview', - body: {session: 'support', token, video: false}, - }, - ]) - }) - - test('refuses client tokens, missing sessions and invalid link tokens before transport', async () => { - server = await startHttpServer({body: {success: true}}) - const client = await clientToken(() => - runCommand('calls links create --session support --json'), - ) - const missing = await apiKey(() => runCommand('calls links create --json')) - const invalid = await withCallLinkStdin('private/bad/token', () => - apiKey(() => runCommand('calls links preview --session support --json')), - ) - for (const response of [client, missing, invalid]) - expect(exitCode(response.error)).toBe(2) - expect(invalid.stderr).not.toContain('private/bad/token') - expect(server.requests).toHaveLength(0) - }) - - test('does not retry a call-link creation failure', async () => { - server = await startHttpServer({ - status: 502, - body: { - error: {code: 'upstream_failure', message: 'Call provider failed'}, - }, - }) - const response = await apiKey(() => - runCommand('calls links create --session support --json'), - ) - expect(response.error).toBeDefined() - expect(server.requests).toHaveLength(1) - }) - test.skipIf( !( 'ringParticipant' in @@ -1225,22 +1129,6 @@ describe('unified calls commands', () => { } }) -async function withCallLinkStdin( - value: string, - run: () => Promise, -): Promise { - const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin') - Object.defineProperty(process, 'stdin', { - configurable: true, - value: Readable.from([value]), - }) - try { - return await run() - } finally { - if (descriptor) Object.defineProperty(process, 'stdin', descriptor) - } -} - function apiError( status: number, code: string,