diff --git a/README.md b/README.md index 7056d5bd..8b8c1e14 100644 --- a/README.md +++ b/README.md @@ -681,6 +681,13 @@ never answers or declines on its own. the answered call. `calls reject` exits with code 4 and `code: "call_not_ringing"` when the call was already answered, declined, or ended. Declining ends the call for every participant. +- **Number restrictions.** When WhatsApp restricts the Number to people it has + already chatted with, `calls place` and `calls invite` exit with code 3 and + `code: "number_restricted"` for anyone else; calls to people it has already + chatted with still work. A call WhatsApp refuses for that reason ends with + `reason: "call_restricted"` in `calls watch`. + `polymorfa listen --event session.restriction_updated` reports the + restriction starting and ending. - **Connections.** `calls leave` closes one connection, named by an 8 to 64 character ID from `A-Z`, `a-z`, `0-9`, `_`, and `-`. The call continues. `calls end` ends the call for everyone. diff --git a/package-lock.json b/package-lock.json index 31d2ef0f..ff07fcae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923093259", + "@polymorfa/sdk": "0.1.0-dev.20260923101121", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,10 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923093259", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923093259.tgz", - "integrity": "sha512-rmSvbH1Tp6gGEPC0asXGaZnX8swxHniDzcK9XaQLTVkMciqjRxDDP+DQeA5jVI6cI3U08Paev15ek43ziCywFQ==", + "version": "0.1.0-dev.20260923101121", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923101121.tgz", + "integrity": "sha512-Oi40GMCvOJhhnR5OTw5SAFLbcFo62zMl8Zv3b11jEh5hY6L572VMDVnJQASz/LkQ9mVV694NjyXdIsywH2v/fA==", + "license": "MIT", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 964991d6..f1358aa3 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923093259", + "@polymorfa/sdk": "0.1.0-dev.20260923101121", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/calls/invite.ts b/src/commands/calls/invite.ts index b47ca60a..7892ff1a 100644 --- a/src/commands/calls/invite.ts +++ b/src/commands/calls/invite.ts @@ -14,7 +14,7 @@ export default class CallsInvite extends BaseCommand { } static override description = - 'Ring another WhatsApp user into a call, turning a 1:1 call into a group call. The call must have a live leg.' + 'Ring another WhatsApp user into a call, turning a 1:1 call into a group call. The call must have a live leg. While WhatsApp restricts the number to people it has already chatted with, inviting anyone else exits with code 3 and number_restricted.' static override examples = [ '<%= config.bin %> calls invite call_123 +15550101', diff --git a/src/commands/calls/place.ts b/src/commands/calls/place.ts index b7d8066a..a0468ba7 100644 --- a/src/commands/calls/place.ts +++ b/src/commands/calls/place.ts @@ -13,7 +13,7 @@ export default class CallsPlace extends BaseCommand { } static override description = - 'Place a WhatsApp call from a session. The placing participant owns the call. Server API keys name the session with --session; client tokens place from the session they are bound to.' + 'Place a WhatsApp call from a session. The placing participant owns the call. Server API keys name the session with --session; client tokens place from the session they are bound to. While WhatsApp restricts the number to people it has already chatted with, a call to anyone else exits with code 3 and number_restricted.' static override examples = [ '<%= config.bin %> calls place +15550100 --session support', diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index 42235d17..4ee70637 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -142,6 +142,26 @@ describe('calls watch and media', () => { ) }) + test('keeps the BanSafe call restriction reason in watched call events', async () => { + server = await startCallsServer({ + onSocket: async (socket) => { + await socket.next() + socket.sendJson({participant: 'server:default', type: 'ready'}) + sendEvent(socket, 'call.received', 'call_3', { + from: '+15550103', + hasVideo: false, + }) + sendEvent(socket, 'call.ended', 'call_3', {reason: 'call_restricted'}) + }, + }) + + const result = await apiKey(() => + runCommand('calls watch --session support --count 2 --json'), + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('"reason":"call_restricted"') + }) + test('exits with an authentication error when the socket is refused with 4401', async () => { server = await startCallsServer({ onSocket: async (socket) => { diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 9c955756..c1fd0090 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -182,6 +182,43 @@ describe('unified calls commands', () => { expect(server.requests).toHaveLength(2) }) + test('reports number restrictions for place and invite in human and JSON output', async () => { + server = await startHttpServer( + apiError( + 403, + 'number_restricted', + 'This number can call only people it has already chatted with.', + 'req_restricted', + ), + ) + + for (const command of [ + 'calls place +15550100 --session support', + 'calls invite call_1 +15550101', + ]) { + const human = await apiKey(() => runCommand(command)) + const json = await apiKey(() => runCommand(`${command} --json`)) + + expect(exitCode(human.error)).toBe(3) + expect(human.stderr).toContain('already chatted with') + expect(human.stderr).toContain('Request ID: req_restricted') + expect(exitCode(json.error)).toBe(3) + expect(JSON.parse(json.stderr)).toMatchObject({ + error: { + category: 'authorization', + code: 'number_restricted', + requestId: 'req_restricted', + }, + }) + } + expect(server.requests.map((request) => request.url)).toEqual([ + '/messaging/voip/calls', + '/messaging/voip/calls', + '/messaging/voip/calls/call_1/participants', + '/messaging/voip/calls/call_1/participants', + ]) + }) + test('reports an authentication failure with the credential redacted', async () => { server = await startHttpServer( apiError(401, 'invalid_credential', `Key ${API_KEY} is invalid.`),