Skip to content
Open
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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,9 @@ polymorfa calls settings set --session support --calls-enabled=false \
--expected-revision 3
polymorfa calls settings set --session support \
--inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10
polymorfa calls settings set --session support \
--simultaneous-calls=true --acknowledge-risk
polymorfa calls settings set --session support --simultaneous-calls=false

polymorfa calls retention get
polymorfa calls retention set --policy compliance --expected-revision 0
Expand Down Expand Up @@ -1081,7 +1084,15 @@ never answers or declines on its own.
SIP trunk; `--sip-claim` controls whether the trunk's answer claims the
call. On a Cloud API session, `--host-cloud-api-calls=true` has Polymorfa
Calls answer incoming calls; with the default `false`, your Graph API
integration answers them. `calls settings set` sends only the flags you pass; the other
integration answers them. `--simultaneous-calls=true` lets a linked-device
number hold any number of calls at once (incoming, placed, and campaign
calls); Cloud API numbers ignore it. WhatsApp's own apps never hold two calls
at once. While it is on, and for 30 days after you turn it off, Ban Insurance
does not cover a ban of the number: a claim opens as `denied` with verdict
`simultaneous_calls`. Turning it on requires `--acknowledge-risk`; without
it the command exits with code 2 and sends nothing. Only a team API key can
turn it on. `--simultaneous-calls=false` needs no acknowledgment.
`calls settings set` sends only the flags you pass; the other
settings keep their values. `calls settings get` and `set` print the
settings `revision`, which increases on every change and is `0` while the
session uses the defaults. Pass `--expected-revision <n>` to apply the
Expand Down Expand Up @@ -1590,7 +1601,9 @@ patches. Customer incident reports and retractions also require an idempotency k
acknowledgement and enforcement appeals remain dashboard-only and are not
available through server-key commands. Claim `amountCents`, `measuredCents`, and
`capCents` are credit quantities with up to six decimal places, not cash minor
units; text output labels them as credits. Use `--json` to retain explanations,
units; text output labels them as credits. `claim view` prints whether
simultaneous calls were on during the claim window when the claim records it;
a claim with verdict `simultaneous_calls` is denied and pays nothing. Use `--json` to retain explanations,
observed account state, collection metadata, action outcomes, cursors, and
request metadata.

Expand Down
9 changes: 8 additions & 1 deletion docs/command-research/calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ calls
[--cursor <cursor>] [--max-pages <n>]
settings
get --session <id>
set --session <id> [--[no-]conference-mode] [--calls-enabled=<true|false>] ...
set --session <id> [--[no-]conference-mode] [--calls-enabled=<true|false>]
[--simultaneous-calls=<true|false> [--acknowledge-risk]] ...
```

## Operation mapping
Expand Down Expand Up @@ -111,6 +112,12 @@ internal SDK module.
Messaging client; the CLI builds that client from the Platform environment.
`calls settings set --expected-revision` sends `expectedRevision`; a stale
revision returns 409 `state_conflict`, which exits with code 4.
`--simultaneous-calls=true` sends `simultaneousCalls: true` with
`acknowledgeRisk: true` only when `--acknowledge-risk` is present; without
it the CLI exits with code 2 before any request. `--acknowledge-risk`
without `--simultaneous-calls=true` is also refused locally, because the
API rejects `acknowledgeRisk` on its own. The API refuses project tokens
turning it on with 403 `permission_denied` (exit code 3).
- `calls stats` and `calls export` accept an organization API key or an
exact-project token (`sessions:read`), resolved like the developer
commands. `--project` (or a project token, which requires it) reads one
Expand Down
17 changes: 16 additions & 1 deletion src/commands/calls/settings/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,23 @@ import {

export default class CallsSettingsSet extends BaseCommand {
static override description =
'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --expected-revision applies the change only if the settings still have that revision (see `calls settings get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. --conference-mode (on by default) lets every participant you connect to a call (apps, bots and SIP trunk callers) hear the WhatsApp party and each other; --no-conference-mode lets each hear only the WhatsApp party. The WhatsApp party always hears all of your participants, and nobody hears their own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk, and --sip-claim controls whether the trunk’s answer claims the call. --host-cloud-api-calls=true has Polymorfa Calls answer a Cloud API session’s incoming calls instead of your Graph API integration. Requires a server API key.'
'Change a session’s call settings through the Platform API. Settings you do not pass keep their current values; --inbound-route sip_trunk without --sip-trunk keeps the stored trunk. --calls-enabled=false turns calling off for the session: placing, answering and joining calls fail, incoming calls are declined and SIP trunks cannot call through it, while calls in progress continue. --expected-revision applies the change only if the settings still have that revision (see `calls settings get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. --conference-mode (on by default) lets every participant you connect to a call (apps, bots and SIP trunk callers) hear the WhatsApp party and each other; --no-conference-mode lets each hear only the WhatsApp party. The WhatsApp party always hears all of your participants, and nobody hears their own audio. --inbound-route sip_trunk also sends incoming calls to --sip-trunk, and --sip-claim controls whether the trunk’s answer claims the call. --host-cloud-api-calls=true has Polymorfa Calls answer a Cloud API session’s incoming calls instead of your Graph API integration. --simultaneous-calls=true lets a linked-device number hold any number of calls at once (incoming, placed and campaign calls); Cloud API numbers ignore it. WhatsApp’s own apps never hold two calls at once, and while it is on, and for 30 days after you turn it off, Ban Insurance does not cover a ban of the number. Turning it on therefore requires --acknowledge-risk, and the command exits with code 2 without changing anything when that flag is missing. Only a team API key can turn it on; --simultaneous-calls=false needs no acknowledgment. Requires a server API key.'

static override examples = [
'<%= config.bin %> calls settings set --session support --calls-enabled=false',
'<%= config.bin %> calls settings set --session support --no-conference-mode',
'<%= config.bin %> calls settings set --session support --calls-enabled=false --expected-revision 3',
'<%= config.bin %> calls settings set --session support --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10',
'<%= config.bin %> calls settings set --session support --inbound-route clients',
'<%= config.bin %> calls settings set --session support --simultaneous-calls=true --acknowledge-risk',
'<%= config.bin %> calls settings set --session support --simultaneous-calls=false',
]

static override flags = {
'acknowledge-risk': Flags.boolean({
description:
'Confirm that turning simultaneous calls on removes Ban Insurance cover for the number. Required with --simultaneous-calls=true.',
}),
'calls-enabled': Flags.string({
description: 'Allow calls on the session (false turns calling off).',
options: ['true', 'false'],
Expand Down Expand Up @@ -50,6 +56,11 @@ export default class CallsSettingsSet extends BaseCommand {
'Whether an answer from the SIP trunk claims the call and stops other clients ringing.',
options: ['true', 'false'],
}),
'simultaneous-calls': Flags.string({
description:
'Let a linked-device number hold any number of calls at once (true requires --acknowledge-risk; false turns it off).',
options: ['true', 'false'],
}),
'sip-trunk': Flags.string({
description: 'SIP trunk ID that receives incoming calls.',
}),
Expand Down Expand Up @@ -86,6 +97,10 @@ export default class CallsSettingsSet extends BaseCommand {
...(flags['sip-trunk'] === undefined
? {}
: {sipTrunkId: flags['sip-trunk']}),
...(flags['simultaneous-calls'] === undefined
? {}
: {simultaneousCalls: flags['simultaneous-calls'] === 'true'}),
...(flags['acknowledge-risk'] ? {acknowledgeRisk: true} : {}),
},
)
this.writeValue(
Expand Down
13 changes: 13 additions & 0 deletions src/services/bansafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,11 +476,24 @@ export function renderClaim(claim: BanSafeClaim): string {
`Measured credits\t${claim.measuredCents}`,
`Cap credits\t${claim.capCents}`,
`Window\t${claim.windowStart} to ${claim.windowEnd}`,
...simultaneousCallsEvidence(claim),
`Summary\t${claim.summary}`,
`Reason\t${claim.reason}`,
].join('\n')
}

/**
* `evidence.simultaneousCalls` comes from the Ban Insurance claim contract
* (polymorfa/polymorfa#559) and is newer than the pinned SDK; delete this
* widening once the pin includes it.
*/
function simultaneousCallsEvidence(claim: BanSafeClaim): string[] {
const value: unknown = Reflect.get(claim.evidence, 'simultaneousCalls')
return typeof value === 'boolean'
? [`Simultaneous calls on in window\t${value ? 'yes' : 'no'}`]
: []
}

export function renderSafeMode(
mode: ProjectSafeMode | SessionSafeMode,
): string {
Expand Down
53 changes: 47 additions & 6 deletions src/services/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
type ResponseMetadata,
type SessionCallSettings,
type SuccessResponse,
type UpdateSessionCallSettingsRequest,
type VoipAcceptCallResult,
type VoipParticipant,
type VoipPlaceCallResult,
Expand Down Expand Up @@ -292,10 +293,28 @@ export async function ringCallParticipant(
return result(response.data, response.metadata)
}

/**
* Simultaneous-calls fields from the Platform call settings contract
* (polymorfa/polymorfa#559). The pinned SDK predates them; delete these
* extensions and use the SDK types directly once the pin includes them.
*/
export type CallSettings = SessionCallSettings & {
readonly simultaneousCalls?: boolean
}

type CallSettingsUpdateBody = UpdateSessionCallSettingsRequest & {
readonly acknowledgeRisk?: boolean
readonly simultaneousCalls?: boolean
}

/** Shown before simultaneous calls turn on, and when the risk is unconfirmed. */
export const SIMULTANEOUS_CALLS_RISK =
'Simultaneous calls let this number hold any number of calls at once. WhatsApp’s own apps never hold two calls at once, so this is unusual for a WhatsApp account. While it is on, and for 30 days after you turn it off, Ban Insurance does not cover a ban of this number.'

export async function viewCallSettings(
client: MessagingClient,
session: string,
): Promise<CallsResult<SessionCallSettings>> {
): Promise<CallsResult<CallSettings>> {
const response = await client.voip.retrieveCallSettings(
validateSession(session),
)
Expand All @@ -311,6 +330,10 @@ export interface CallSettingsChanges {
readonly inboundRoute?: CallInboundRoute
readonly sipClaim?: boolean
readonly sipTrunkId?: string
/** `true` also requires `acknowledgeRisk`; see SIMULTANEOUS_CALLS_RISK. */
readonly simultaneousCalls?: boolean
/** Confirms the Ban Insurance risk of turning simultaneous calls on. */
readonly acknowledgeRisk?: boolean
}

/**
Expand All @@ -321,15 +344,27 @@ export async function updateCallSettings(
client: MessagingClient,
session: string,
changes: CallSettingsChanges,
): Promise<CallsResult<SessionCallSettings>> {
): Promise<CallsResult<CallSettings>> {
const name = validateSession(session)
const {expectedRevision, ...settings} = changes
const {expectedRevision, acknowledgeRisk, ...settings} = changes
if (acknowledgeRisk === true && changes.simultaneousCalls !== true) {
throw new PolymorfaConfigurationError(
'--acknowledge-risk applies only with --simultaneous-calls=true.',
'acknowledge-risk',
)
}
if (Object.values(settings).every((value) => value === undefined)) {
throw new PolymorfaConfigurationError(
'Provide at least one setting to change.',
'calls-enabled',
)
}
if (changes.simultaneousCalls === true && acknowledgeRisk !== true) {
throw new PolymorfaConfigurationError(
`${SIMULTANEOUS_CALLS_RISK} Re-run with --acknowledge-risk to turn simultaneous calls on.`,
'acknowledge-risk',
)
}
if (changes.inboundRoute === 'clients' && changes.sipTrunkId !== undefined) {
throw new PolymorfaConfigurationError(
'--sip-trunk requires --inbound-route sip_trunk.',
Expand All @@ -340,7 +375,7 @@ export async function updateCallSettings(
const inboundRoute =
changes.inboundRoute ??
(changes.sipTrunkId === undefined ? undefined : 'sip_trunk')
const response = await client.voip.updateCallSettings(name, {
const body: CallSettingsUpdateBody = {
...(changes.callsEnabled === undefined
? {}
: {callsEnabled: changes.callsEnabled}),
Expand All @@ -355,8 +390,13 @@ export async function updateCallSettings(
...(changes.hostCloudApiCalls === undefined
? {}
: {hostCloudApiCalls: changes.hostCloudApiCalls}),
...(changes.simultaneousCalls === undefined
? {}
: {simultaneousCalls: changes.simultaneousCalls}),
...(changes.simultaneousCalls === true ? {acknowledgeRisk: true} : {}),
...(expectedRevision === undefined ? {} : {expectedRevision}),
})
}
const response = await client.voip.updateCallSettings(name, body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Upgrade the SDK before exposing standalone simultaneous calls

With the pinned @polymorfa/sdk version, voip.updateCallSettings still validates requests against the old settings fields and throws Send at least one call setting to change. when the body contains only simultaneousCalls (plus acknowledgeRisk). Consequently the newly documented standalone enable and disable commands fail before sending an HTTP request; the added success tests conceal this by always including --conference-mode. Upgrade the SDK or otherwise bypass its obsolete validator before exposing these commands.

Useful? React with 👍 / 👎.

return result(response.data.data, response.metadata)
}

Expand Down Expand Up @@ -601,7 +641,7 @@ export function renderCallParticipant(participant: VoipParticipant): string {

export function renderCallSettings(
session: string,
settings: SessionCallSettings,
settings: CallSettings,
): string {
return [
`Session\t${session}`,
Expand All @@ -615,6 +655,7 @@ export function renderCallSettings(
]
: []),
`Cloud API calls answered by\t${settings.hostCloudApiCalls === true ? 'Polymorfa Calls' : 'your Graph API integration'}`,
`Simultaneous calls\t${settings.simultaneousCalls === true ? 'on (Ban Insurance does not cover this number)' : 'off'}`,
`Revision\t${settings.revision ?? 0}`,
`Updated\t${settings.updatedAt ?? 'never (defaults)'}`,
].join('\n')
Expand Down
60 changes: 60 additions & 0 deletions test/commands/bansafe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,66 @@ describe('BanSafe commands', () => {
expect(result.stdout).toContain('Measured credits\t0.000001')
expect(result.stdout).toContain('Cap credits\t50.5')
expect(result.stdout).not.toContain('cents')
expect(result.stdout).not.toContain('Simultaneous calls')
})

test('shows the simultaneous calls verdict and evidence on a claim', async () => {
server = await startHttpServer({
body: {
data: {
amountCents: 0,
capCents: 50,
createdAt: '2026-10-11T00:00:00Z',
decidedAt: '2026-10-11T00:00:00Z',
evidence: {
attributionRuleVersion: 2,
criticalFindingDays: 0,
deviceEvidence: true,
measuredHours: 24,
otherDevices: 0,
restrictedInWindow: true,
sharedConnection: false,
simultaneousCalls: true,
windowDays: 30,
},
id: 'claim_2',
incidentId: 'incident_2',
measuredCents: 10,
paidAt: null,
phoneNumber: '+1',
projectId: 'p1',
reason:
'Simultaneous calls were on for this number during the 30 days before the ban. Ban Insurance does not cover a number with simultaneous calls on.',
session: 's1',
sessionId: 'id1',
status: 'denied',
summary:
'This claim was not approved because simultaneous calls were on for this number.',
verdict: 'simultaneous_calls',
windowEnd: '2026-10-11T00:00:00Z',
windowStart: '2026-09-11T00:00:00Z',
},
},
})

const human = await platformEnvironment(() =>
runCommand('bansafe claim view claim_2'),
)
const json = await platformEnvironment(() =>
runCommand('bansafe claim view claim_2 --json'),
)

expect(human.error).toBeUndefined()
expect(human.stdout).toContain('Status\tdenied')
expect(human.stdout).toContain('Verdict\tsimultaneous_calls')
expect(human.stdout).toContain('Simultaneous calls on in window\tyes')
expect(json.error).toBeUndefined()
expect(JSON.parse(json.stdout)).toMatchObject({
data: {
evidence: {simultaneousCalls: true},
verdict: 'simultaneous_calls',
},
})
})

test('requires idempotency for Health policy updates', async () => {
Expand Down
Loading
Loading