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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion src/commands/calls/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/commands/calls/place.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
20 changes: 20 additions & 0 deletions test/commands/calls-sockets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
37 changes: 37 additions & 0 deletions test/commands/calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`),
Expand Down