From cb1fb9946b1c7bfec4f022ea525b9f5a64172715 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 11 Oct 2026 04:04:49 +0300 Subject: [PATCH] Add simultaneous calls to calls settings and Ban Insurance claim output calls settings set gains --simultaneous-calls= and --acknowledge-risk. Turning simultaneous calls on without the acknowledgment exits with code 2 before any request. Settings output and claim view show the new fields from polymorfa/polymorfa#559. Co-Authored-By: Claude --- README.md | 17 +++++- docs/command-research/calls.md | 9 ++- src/commands/calls/settings/set.ts | 17 +++++- src/services/bansafe.ts | 13 +++++ src/services/calls.ts | 53 ++++++++++++++++-- test/commands/bansafe.test.ts | 60 ++++++++++++++++++++ test/commands/calls.test.ts | 88 +++++++++++++++++++++++++++++- 7 files changed, 246 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4c14a508..6300d7c7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` to apply the @@ -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. diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index b85b1798..e3cbc76d 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -53,7 +53,8 @@ calls [--cursor ] [--max-pages ] settings get --session - set --session [--[no-]conference-mode] [--calls-enabled=] ... + set --session [--[no-]conference-mode] [--calls-enabled=] + [--simultaneous-calls= [--acknowledge-risk]] ... ``` ## Operation mapping @@ -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 diff --git a/src/commands/calls/settings/set.ts b/src/commands/calls/settings/set.ts index d1c3a686..5fb6dacb 100644 --- a/src/commands/calls/settings/set.ts +++ b/src/commands/calls/settings/set.ts @@ -9,7 +9,7 @@ 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', @@ -17,9 +17,15 @@ export default class CallsSettingsSet extends BaseCommand { '<%= 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'], @@ -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.', }), @@ -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( diff --git a/src/services/bansafe.ts b/src/services/bansafe.ts index e42d5b5e..f6a194ec 100644 --- a/src/services/bansafe.ts +++ b/src/services/bansafe.ts @@ -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 { diff --git a/src/services/calls.ts b/src/services/calls.ts index 10af59dc..07a25ae3 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -32,6 +32,7 @@ import { type ResponseMetadata, type SessionCallSettings, type SuccessResponse, + type UpdateSessionCallSettingsRequest, type VoipAcceptCallResult, type VoipParticipant, type VoipPlaceCallResult, @@ -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> { +): Promise> { const response = await client.voip.retrieveCallSettings( validateSession(session), ) @@ -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 } /** @@ -321,15 +344,27 @@ export async function updateCallSettings( client: MessagingClient, session: string, changes: CallSettingsChanges, -): Promise> { +): Promise> { 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.', @@ -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}), @@ -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) return result(response.data.data, response.metadata) } @@ -601,7 +641,7 @@ export function renderCallParticipant(participant: VoipParticipant): string { export function renderCallSettings( session: string, - settings: SessionCallSettings, + settings: CallSettings, ): string { return [ `Session\t${session}`, @@ -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') diff --git a/test/commands/bansafe.test.ts b/test/commands/bansafe.test.ts index 57f0e315..5dcdd036 100644 --- a/test/commands/bansafe.test.ts +++ b/test/commands/bansafe.test.ts @@ -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 () => { diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index f60ca33a..1118b452 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -488,7 +488,7 @@ describe('unified calls commands', () => { expect(viewed.error).toBeUndefined() expect(viewed.stdout).toBe( - 'Session\tsupport/main\nCalls\ton\nConference mode\toff\nIncoming calls\tclients\nCloud API calls answered by\tyour Graph API integration\nRevision\t0\nUpdated\t2026-09-16T10:00:00.000Z\n', + 'Session\tsupport/main\nCalls\ton\nConference mode\toff\nIncoming calls\tclients\nCloud API calls answered by\tyour Graph API integration\nSimultaneous calls\toff\nRevision\t0\nUpdated\t2026-09-16T10:00:00.000Z\n', ) expect(updated.error).toBeUndefined() expect(jsonData(updated.stdout)).toEqual({ @@ -577,6 +577,92 @@ describe('unified calls commands', () => { expect(exitCode(empty.error)).toBe(2) }) + test('turns simultaneous calls on only with --acknowledge-risk', async () => { + const on = { + callsEnabled: true, + conferenceMode: true, + hostCloudApiCalls: false, + inboundRoute: 'clients', + revision: 2, + simultaneousCalls: true, + sipClaim: true, + sipTrunkId: null, + updatedAt: '2026-10-11T10:00:00.000Z', + } + server = await startHttpSequenceServer([ + {body: {data: on, success: true}}, + {body: {data: on, success: true}}, + { + body: { + data: {...on, revision: 3, simultaneousCalls: false}, + success: true, + }, + }, + ]) + + const unacknowledged = await apiKey(() => + runCommand( + 'calls settings set --session support --simultaneous-calls=true --json', + ), + ) + const stray = await apiKey(() => + runCommand( + 'calls settings set --session support --conference-mode --acknowledge-risk', + ), + ) + const offWithAck = await apiKey(() => + runCommand( + 'calls settings set --session support --simultaneous-calls=false --acknowledge-risk', + ), + ) + const acknowledged = await apiKey(() => + runCommand( + 'calls settings set --session support --conference-mode --simultaneous-calls=true --acknowledge-risk', + ), + ) + const json = await apiKey(() => + runCommand( + 'calls settings set --session support --conference-mode --simultaneous-calls=true --acknowledge-risk --expected-revision 1 --json', + ), + ) + const off = await apiKey(() => + runCommand( + 'calls settings set --session support --conference-mode --simultaneous-calls=false', + ), + ) + + expect(exitCode(unacknowledged.error)).toBe(2) + expect(JSON.parse(unacknowledged.stderr.trim())).toMatchObject({ + error: {category: 'configuration'}, + }) + expect(unacknowledged.stderr).toContain( + 'Ban Insurance does not cover a ban of this number', + ) + expect(unacknowledged.stderr).toContain('--acknowledge-risk') + expect(exitCode(stray.error)).toBe(2) + expect(exitCode(offWithAck.error)).toBe(2) + expect(acknowledged.error).toBeUndefined() + expect(acknowledged.stdout).toContain( + 'Simultaneous calls\ton (Ban Insurance does not cover this number)', + ) + expect(json.error).toBeUndefined() + expect(jsonData(json.stdout)).toMatchObject({simultaneousCalls: true}) + expect(off.error).toBeUndefined() + expect(off.stdout).toContain('Simultaneous calls\toff') + expect( + server.requests.map(({body}) => JSON.parse(body) as unknown), + ).toEqual([ + {acknowledgeRisk: true, conferenceMode: true, simultaneousCalls: true}, + { + acknowledgeRisk: true, + conferenceMode: true, + expectedRevision: 1, + simultaneousCalls: true, + }, + {conferenceMode: true, simultaneousCalls: false}, + ]) + }) + test('guards a change with the expected revision and exits 4 when stale', async () => { server = await startHttpSequenceServer([ {