Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
49 changes: 49 additions & 0 deletions src/commands/calls/hand.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
51 changes: 51 additions & 0 deletions src/commands/calls/list.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
8 changes: 8 additions & 0 deletions src/commands/calls/media.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -74,6 +80,7 @@ export default class CallsMedia extends BaseCommand {

async run(): Promise<void> {
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 <file>.',
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions src/commands/calls/place-group.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
55 changes: 55 additions & 0 deletions src/commands/calls/react.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
30 changes: 30 additions & 0 deletions src/commands/calls/ring.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
32 changes: 32 additions & 0 deletions src/commands/calls/view.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
Loading