From b156fbe80f74e42cc4fe9b3f35406e85b2e70fd4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 08:24:17 +0200 Subject: [PATCH 1/2] Pin the Calls SDK prerelease and add call-link commands Pin @polymorfa/sdk 0.1.0-dev.20260926235309, the first dev prerelease containing the merged Calls SDK (sdks #306). Group placement, re-ring, reactions, raised hands, media controls and call detail now use the SDK methods instead of refusing with a dependency error. Add calls link create and calls link preview for server-credential call links. Preview reads the link or token from standard input so it stays out of shell history, and derives voice or video from a full link. --- README.md | 40 +++-- package-lock.json | 8 +- package.json | 5 +- src/commands/calls/link/create.ts | 36 ++++ src/commands/calls/link/preview.ts | 49 ++++++ src/commands/calls/view.ts | 2 +- src/services/call-analytics.ts | 16 +- src/services/call-links.ts | 113 ++++++++++++ test/commands/call-links.test.ts | 242 ++++++++++++++++++++++++++ test/release/check-prerelease.test.ts | 2 +- 10 files changed, 478 insertions(+), 35 deletions(-) create mode 100644 src/commands/calls/link/create.ts create mode 100644 src/commands/calls/link/preview.ts create mode 100644 src/services/call-links.ts create mode 100644 test/commands/call-links.test.ts diff --git a/README.md b/README.md index e2755db..a216276 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ prerelease in `package.json`. The version output includes both runtime components: ```text -polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20260926005918 +polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20260926235309 ``` The SDK component reports the version built into the installed SDK. Dev @@ -781,9 +781,8 @@ 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. +`calls place-group` places a group call and `calls ring` rings an existing +participant again: ```shell polymorfa calls place-group --session support --to +15550100 --to +15550101 @@ -2122,16 +2121,31 @@ 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. +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. +### Call links + +```sh +polymorfa calls link create --session support +polymorfa calls link create --session support --video --json +printf '%s' "$CALL_LINK" | polymorfa calls link preview --session support +``` + +`calls link create` creates a WhatsApp call link from a Linked Device Number +and prints its URL. It does not place or join a call. The request is never +retried; if the outcome is unknown, check before creating another link. +Anyone with the link can use it in WhatsApp, so keep it private. + +`calls link preview` reads a link without joining the call and prints its +creator, whether joining needs approval, and whether WhatsApp reports the +Number as a link administrator. It reads the link, or its bare token, from +standard input, so the token stays out of shell history and process listings. +A full link sets voice or video; pass `--video` with a bare video-link token. + +Both commands require a server API key or project token, a Linked Device +Number, and calling enabled for that Number. Client tokens are refused before +a request is sent, and Cloud API-only Numbers are refused by the API. ### Stored call records diff --git a/package-lock.json b/package-lock.json index bac5279..6c65f5a 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.20260926005918", + "@polymorfa/sdk": "0.1.0-dev.20260926235309", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260926005918", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260926005918.tgz", - "integrity": "sha512-PAkVAqN5QNSKcoT4i7DJHDRp5RfKbDmiJUDurkgxJYOyFR4d+wJ65HrAqkY5KGvBjtANsjkqRczmt8gQRAnGVw==", + "version": "0.1.0-dev.20260926235309", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260926235309.tgz", + "integrity": "sha512-jRURp+kn3l5/1PHUExcmGJGy3li6z5L6vX9i1Nhw2h+fWat0PeYKXWM1SY0yJqXPnpUFO0DdFs772/798SfjLw==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 03953d3..483f49d 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.20260926005918", + "@polymorfa/sdk": "0.1.0-dev.20260926235309", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -108,6 +108,9 @@ "calls": { "description": "Place and manage calls, check consent, carry audio, and report on past calls." }, + "calls:link": { + "description": "Create and preview WhatsApp call links." + }, "calls:opt-outs": { "description": "Manage the team do-not-call list." }, diff --git a/src/commands/calls/link/create.ts b/src/commands/calls/link/create.ts new file mode 100644 index 0000000..c04b1a7 --- /dev/null +++ b/src/commands/calls/link/create.ts @@ -0,0 +1,36 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + createCallLink, + renderCreatedCallLink, +} from '../../../services/call-links.js' + +export default class CallsLinkCreate extends BaseCommand { + static override description = + 'Create a WhatsApp call link from a Linked Device Number. It does not place or join a call. Requires a server API key or project token and calling enabled for the Number; client tokens and Cloud API-only Numbers are refused. The request is never retried: if the outcome is unknown, check before creating another link. Keep the link private.' + + static override examples = [ + '<%= config.bin %> calls link create --session support', + '<%= config.bin %> calls link create --session support --video --json', + ] + + static override flags = { + session: Flags.string({ + description: 'Linked Device session that owns the link.', + required: true, + }), + video: Flags.boolean({description: 'Create a video link.'}), + } + + static override summary = 'Create a WhatsApp call link.' + + async run(): Promise { + const {flags} = await this.parse(CallsLinkCreate) + const result = await createCallLink( + await this.messagingClient(flags, {serverCredentialOnly: 'Call link'}), + {session: flags.session, video: flags.video}, + ) + this.writeValue(result, renderCreatedCallLink(result.data), flags) + } +} diff --git a/src/commands/calls/link/preview.ts b/src/commands/calls/link/preview.ts new file mode 100644 index 0000000..21dd6c2 --- /dev/null +++ b/src/commands/calls/link/preview.ts @@ -0,0 +1,49 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {readSecretFromStdin} from '../../../input/stdin.js' +import { + parseCallLink, + previewCallLink, + renderPreviewedCallLink, +} from '../../../services/call-links.js' + +export default class CallsLinkPreview extends BaseCommand { + static override description = + 'Read a WhatsApp call link through a Linked Device Number without joining the call. Pipe the link, or its token, on standard input so it stays out of shell history and process listings. A full link sets voice or video; pass --video with a bare video-link token. Requires a server API key or project token.' + + static override examples = [ + 'printf \'%s\' "$CALL_LINK" | <%= config.bin %> calls link preview --session support', + 'printf \'%s\' "$TOKEN" | <%= config.bin %> calls link preview --session support --video --json', + ] + + static override flags = { + session: Flags.string({ + description: 'Linked Device session that reads the link.', + required: true, + }), + video: Flags.boolean({ + description: + 'The token belongs to a video link. Not needed with a full link.', + }), + } + + static override summary = 'Preview a WhatsApp call link.' + + async run(): Promise { + const {flags} = await this.parse(CallsLinkPreview) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Call link', + }) + const input = await readSecretFromStdin() + this.registerSecret(input) + const link = parseCallLink(input) + this.registerSecret(link.token) + const result = await previewCallLink(client, { + link, + session: flags.session, + ...(flags.video ? {video: true} : {}), + }) + this.writeValue(result, renderPreviewedCallLink(result.data), flags) + } +} diff --git a/src/commands/calls/view.ts b/src/commands/calls/view.ts index c915cdc..6238b48 100644 --- a/src/commands/calls/view.ts +++ b/src/commands/calls/view.ts @@ -9,7 +9,7 @@ import { 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.' + '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.' static override args = { id: Args.string({required: true, description: 'Call ID from calls list.'}), } diff --git a/src/services/call-analytics.ts b/src/services/call-analytics.ts index 9f22323..a6b3e63 100644 --- a/src/services/call-analytics.ts +++ b/src/services/call-analytics.ts @@ -1,8 +1,6 @@ import { Client, PolymorfaServerError, - PolymorfaConfigurationError, - type ApiResponse, type CallRecord, PolymorfaValidationError, type CallStats, @@ -133,19 +131,7 @@ export class CallAnalyticsApi { throw invalid( 'Call ID must contain 1 to 128 letters, digits, underscores or hyphens.', ) - const calls = this.calls as typeof this.calls & { - retrieve?: ( - id: string, - params: Query, - ) => Promise> - } - if (typeof calls.retrieve !== 'function') { - throw new PolymorfaConfigurationError( - 'Call detail requires a published SDK version that includes Client.calls.retrieve. This CLI has not activated a source-only SDK pin.', - 'sdk', - ) - } - const response = await calls.retrieve(callId, query) + const response = await this.calls.retrieve(callId, query) const data = response.data if ( !data || diff --git a/src/services/call-links.ts b/src/services/call-links.ts new file mode 100644 index 0000000..c94a1cd --- /dev/null +++ b/src/services/call-links.ts @@ -0,0 +1,113 @@ +import { + type MessagingClient, + PolymorfaConfigurationError, + PolymorfaValidationError, + type VoipCreatedCallLink, + type VoipPreviewedCallLink, +} from '@polymorfa/sdk' + +import type {CallsResult} from './calls.js' + +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{1,256}$/ +const LINK_PATTERN = + /^https:\/\/call\.whatsapp\.com\/(voice|video)\/([A-Za-z0-9_-]{1,256})\/?$/ + +export interface ParsedCallLink { + readonly token: string + /** Set when the input was a full link, whose path names the media type. */ + readonly video?: boolean +} + +/** Accept a full WhatsApp call link or its bare token. */ +export function parseCallLink(input: string): ParsedCallLink { + const value = input.trim() + const link = LINK_PATTERN.exec(value) + if (link !== null) return {token: link[2]!, video: link[1] === 'video'} + if (TOKEN_PATTERN.test(value)) return {token: value} + throw new PolymorfaValidationError( + 'Standard input must contain one https://call.whatsapp.com/voice/… or /video/… link, or its token.', + ) +} + +function session(value: string): string { + const trimmed = value.trim() + if (trimmed.length === 0 || trimmed.length > 128) { + throw new PolymorfaConfigurationError( + 'Session must contain 1 to 128 characters.', + 'session', + ) + } + return trimmed +} + +export async function createCallLink( + client: MessagingClient, + input: {readonly session: string; readonly video?: boolean}, +): Promise> { + const response = await client.voip.createCallLink({ + session: session(input.session), + video: input.video === true, + }) + return {data: response.data.data, metadata: metadata(response.metadata)} +} + +export async function previewCallLink( + client: MessagingClient, + input: { + readonly link: ParsedCallLink + readonly session: string + readonly video?: boolean + }, +): Promise> { + const {link} = input + if ( + link.video !== undefined && + input.video !== undefined && + link.video !== input.video + ) { + throw new PolymorfaValidationError( + `The link is a ${link.video ? 'video' : 'voice'} link; remove --video or pass the matching link.`, + ) + } + const response = await client.voip.previewCallLink({ + session: session(input.session), + token: link.token, + video: link.video ?? input.video === true, + }) + return {data: response.data.data, metadata: metadata(response.metadata)} +} + +export function renderCreatedCallLink(link: VoipCreatedCallLink): string { + return [ + `Link\t${link.url}`, + `Session\t${link.session}`, + `Video\t${link.video ? 'yes' : 'no'}`, + 'Anyone with this link can use it in WhatsApp. Keep it private. Creating it did not place or join a call.', + ].join('\n') +} + +export function renderPreviewedCallLink(link: VoipPreviewedCallLink): string { + const creator = + link.creator.phoneNumber ?? link.creator.username ?? link.creator.id + return [ + `Session\t${link.session}`, + `Video\t${link.video ? 'yes' : 'no'}`, + `Creator\t${creator}`, + `Approval required\t${link.approvalRequired ? 'yes' : 'no'}`, + `Number is link admin\t${link.isAdmin ? 'yes' : 'no'}`, + ].join('\n') +} + +function metadata(value: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number +}): CallsResult['metadata'] { + return { + attempts: value.attempts, + status: value.status, + ...(value.apiVersion === undefined ? {} : {apiVersion: value.apiVersion}), + ...(value.requestId === undefined ? {} : {requestId: value.requestId}), + } +} diff --git a/test/commands/call-links.test.ts b/test/commands/call-links.test.ts new file mode 100644 index 0000000..51aead0 --- /dev/null +++ b/test/commands/call-links.test.ts @@ -0,0 +1,242 @@ +import {runCommand} from '@oclif/test' +import {Readable} from 'node:stream' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const CLIENT_TOKEN = 'pmfa_ct_browser_token' +const TOKEN = 'AbCdEf0123456789_link-token' + +describe('call link commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('creates a voice link without an idempotency key', async () => { + server = await startHttpServer({ + body: { + success: true, + data: { + session: 'support', + token: TOKEN, + url: `https://call.whatsapp.com/voice/${TOKEN}`, + video: false, + }, + }, + headers: {'request-id': 'req_link'}, + status: 201, + }) + + const result = await apiKey(() => + runCommand('calls link create --session support'), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + `Link\thttps://call.whatsapp.com/voice/${TOKEN}`, + ) + expect(result.stdout).toContain('Keep it private') + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/voip/call-links', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBeUndefined() + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + session: 'support', + video: false, + }) + }) + + test('creates a video link as JSON', async () => { + server = await startHttpServer({ + body: { + success: true, + data: { + session: 'support', + token: TOKEN, + url: `https://call.whatsapp.com/video/${TOKEN}`, + video: true, + }, + }, + status: 201, + }) + + const result = await apiKey(() => + runCommand('calls link create --session support --video --json'), + ) + + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual({ + session: 'support', + token: TOKEN, + url: `https://call.whatsapp.com/video/${TOKEN}`, + video: true, + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + session: 'support', + video: true, + }) + }) + + test('previews a full link read from standard input', async () => { + server = await startHttpServer({ + body: { + success: true, + data: { + session: 'support', + video: true, + creator: {id: '1637209842659371410', phoneNumber: '+15550100'}, + approvalRequired: true, + isAdmin: false, + }, + }, + }) + + const result = await withStdin( + `https://call.whatsapp.com/video/${TOKEN}\n`, + () => apiKey(() => runCommand('calls link preview --session support')), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Creator\t+15550100') + expect(result.stdout).toContain('Approval required\tyes') + expect(result.stdout).not.toContain(TOKEN) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/voip/call-links/preview', + }) + expect(server.requests[0]?.url).not.toContain(TOKEN) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + session: 'support', + token: TOKEN, + video: true, + }) + }) + + test('previews a bare token with --video', async () => { + server = await startHttpServer({ + body: { + success: true, + data: { + session: 'support', + video: true, + creator: {id: '1637209842659371410'}, + approvalRequired: false, + isAdmin: true, + }, + }, + }) + + const result = await withStdin(TOKEN, () => + apiKey(() => + runCommand('calls link preview --session support --video --json'), + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + session: 'support', + token: TOKEN, + video: true, + }) + }) + + test('refuses a voice link with --video before any request', async () => { + server = await startHttpServer({body: {success: true}}) + + const result = await withStdin( + `https://call.whatsapp.com/voice/${TOKEN}`, + () => + apiKey(() => + runCommand('calls link preview --session support --video'), + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('voice link') + expect(server.requests).toHaveLength(0) + }) + + test('refuses malformed input without echoing it', async () => { + server = await startHttpServer({body: {success: true}}) + const input = 'https://example.com/not-a-call-link?secret=abc' + + const result = await withStdin(input, () => + apiKey(() => runCommand('calls link preview --session support')), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('call.whatsapp.com') + expect(result.stderr).not.toContain('secret=abc') + expect(server.requests).toHaveLength(0) + }) + + test('refuses client tokens before reading the link', async () => { + server = await startHttpServer({body: {success: true}}) + + const create = await clientToken(() => + runCommand('calls link create --session support'), + ) + const preview = await withStdin(TOKEN, () => + clientToken(() => runCommand('calls link preview --session support')), + ) + + expect(exitCode(create.error)).toBe(2) + expect(create.stderr).toContain('Call link commands require') + expect(exitCode(preview.error)).toBe(2) + expect(preview.stderr).toContain('Call link commands require') + expect(server.requests).toHaveLength(0) + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } + + async function clientToken(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: CLIENT_TOKEN, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} + +async function withStdin(value: string, run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin') + Object.defineProperty(process, 'stdin', { + configurable: true, + value: Readable.from([value]), + }) + try { + return await run() + } finally { + if (descriptor !== undefined) + Object.defineProperty(process, 'stdin', descriptor) + } +} diff --git a/test/release/check-prerelease.test.ts b/test/release/check-prerelease.test.ts index 1faa33c..12ba296 100644 --- a/test/release/check-prerelease.test.ts +++ b/test/release/check-prerelease.test.ts @@ -56,7 +56,7 @@ describe('npm prerelease guard', () => { test('accepts the reviewed manifest and lockfile', () => { const result = runWith() expect(result.status).toBe(0) - expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20260926005918') + expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20260926235309') }) test('rejects the default latest tag', () => { From 114fa3fe49ba1bb4a0d9a9fb9cab656e26e1107a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 08:32:02 +0200 Subject: [PATCH 2/2] Address local review of call-link commands Refuse an interactive terminal and report empty stdin as a missing link. Accept a pasted link inside share text, with a query or fragment or an uppercase host, and refuse two different links or short bare tokens. Drop the unreachable pre-publication SDK guards and their skipped tests now that the SDK is pinned, and reuse the shared Calls helpers. --- README.md | 9 +-- src/commands/calls/link/preview.ts | 22 ++++++- src/services/call-links.ts | 68 ++++++++----------- src/services/calls.ts | 82 +++-------------------- test/commands/call-links.test.ts | 58 +++++++++++++++- test/commands/call-records.test.ts | 95 +++++++++++---------------- test/commands/calls-social.test.ts | 8 +-- test/commands/calls.test.ts | 102 +++++++++-------------------- 8 files changed, 185 insertions(+), 259 deletions(-) diff --git a/README.md b/README.md index a216276..0991fd4 100644 --- a/README.md +++ b/README.md @@ -2160,9 +2160,6 @@ 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. +`calls view` reads one stored call. Its JSON output includes bounded event +history, participant and connection lifetimes, and allowlisted diagnostics. It +does not retrieve media or webhook deliveries. diff --git a/src/commands/calls/link/preview.ts b/src/commands/calls/link/preview.ts index 21dd6c2..aff86ce 100644 --- a/src/commands/calls/link/preview.ts +++ b/src/commands/calls/link/preview.ts @@ -1,4 +1,5 @@ import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' import {BaseCommand} from '../../../base-command.js' import {readSecretFromStdin} from '../../../input/stdin.js' @@ -35,7 +36,7 @@ export default class CallsLinkPreview extends BaseCommand { const client = await this.messagingClient(flags, { serverCredentialOnly: 'Call link', }) - const input = await readSecretFromStdin() + const input = await readCallLinkFromStdin() this.registerSecret(input) const link = parseCallLink(input) this.registerSecret(link.token) @@ -47,3 +48,22 @@ export default class CallsLinkPreview extends BaseCommand { this.writeValue(result, renderPreviewedCallLink(result.data), flags) } } + +async function readCallLinkFromStdin(): Promise { + if (process.stdin.isTTY === true) { + throw new PolymorfaConfigurationError( + 'Pipe the call link on standard input; it is never read from an argument or typed at a visible prompt.', + 'stdin', + ) + } + try { + return await readSecretFromStdin() + } catch (error) { + if (error instanceof PolymorfaConfigurationError) + throw new PolymorfaConfigurationError( + 'Standard input must contain one call link or token, up to 64 KiB.', + 'stdin', + ) + throw error + } +} diff --git a/src/services/call-links.ts b/src/services/call-links.ts index c94a1cd..e913b52 100644 --- a/src/services/call-links.ts +++ b/src/services/call-links.ts @@ -1,16 +1,18 @@ import { type MessagingClient, - PolymorfaConfigurationError, PolymorfaValidationError, type VoipCreatedCallLink, type VoipPreviewedCallLink, } from '@polymorfa/sdk' -import type {CallsResult} from './calls.js' +import {type CallsResult, result, validateSession, yesNo} from './calls.js' -const TOKEN_PATTERN = /^[A-Za-z0-9_-]{1,256}$/ +// A pasted link may sit in share text and carry a query or fragment; only its +// path names the media type and token. const LINK_PATTERN = - /^https:\/\/call\.whatsapp\.com\/(voice|video)\/([A-Za-z0-9_-]{1,256})\/?$/ + /https:\/\/call\.whatsapp\.com\/(voice|video)\/([A-Za-z0-9_-]{8,256})(?=$|[/?#\s.,;:!)"'>\]])/gi +// Bare tokens are much longer; a short word is a paste mistake, not a token. +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{8,256}$/ export interface ParsedCallLink { readonly token: string @@ -18,37 +20,35 @@ export interface ParsedCallLink { readonly video?: boolean } -/** Accept a full WhatsApp call link or its bare token. */ +/** Accept one WhatsApp call link, possibly inside share text, or its token. */ export function parseCallLink(input: string): ParsedCallLink { const value = input.trim() - const link = LINK_PATTERN.exec(value) - if (link !== null) return {token: link[2]!, video: link[1] === 'video'} + const links = new Map() + for (const [, kind, token] of value.matchAll(LINK_PATTERN)) + links.set(`${kind!.toLowerCase()}/${token!}`, { + token: token!, + video: kind!.toLowerCase() === 'video', + }) + if (links.size === 1) return [...links.values()][0]! + if (links.size > 1) + throw new PolymorfaValidationError( + 'Standard input contains more than one call link; pipe exactly one.', + ) if (TOKEN_PATTERN.test(value)) return {token: value} throw new PolymorfaValidationError( 'Standard input must contain one https://call.whatsapp.com/voice/… or /video/… link, or its token.', ) } -function session(value: string): string { - const trimmed = value.trim() - if (trimmed.length === 0 || trimmed.length > 128) { - throw new PolymorfaConfigurationError( - 'Session must contain 1 to 128 characters.', - 'session', - ) - } - return trimmed -} - export async function createCallLink( client: MessagingClient, input: {readonly session: string; readonly video?: boolean}, ): Promise> { const response = await client.voip.createCallLink({ - session: session(input.session), + session: validateSession(input.session), video: input.video === true, }) - return {data: response.data.data, metadata: metadata(response.metadata)} + return result(response.data.data, response.metadata) } export async function previewCallLink( @@ -66,22 +66,22 @@ export async function previewCallLink( link.video !== input.video ) { throw new PolymorfaValidationError( - `The link is a ${link.video ? 'video' : 'voice'} link; remove --video or pass the matching link.`, + `The link is a ${link.video ? 'video' : 'voice'} link; remove --video or pipe the matching link.`, ) } const response = await client.voip.previewCallLink({ - session: session(input.session), + session: validateSession(input.session), token: link.token, video: link.video ?? input.video === true, }) - return {data: response.data.data, metadata: metadata(response.metadata)} + return result(response.data.data, response.metadata) } export function renderCreatedCallLink(link: VoipCreatedCallLink): string { return [ `Link\t${link.url}`, `Session\t${link.session}`, - `Video\t${link.video ? 'yes' : 'no'}`, + `Video\t${yesNo(link.video)}`, 'Anyone with this link can use it in WhatsApp. Keep it private. Creating it did not place or join a call.', ].join('\n') } @@ -91,23 +91,9 @@ export function renderPreviewedCallLink(link: VoipPreviewedCallLink): string { link.creator.phoneNumber ?? link.creator.username ?? link.creator.id return [ `Session\t${link.session}`, - `Video\t${link.video ? 'yes' : 'no'}`, + `Video\t${yesNo(link.video)}`, `Creator\t${creator}`, - `Approval required\t${link.approvalRequired ? 'yes' : 'no'}`, - `Number is link admin\t${link.isAdmin ? 'yes' : 'no'}`, + `Approval required\t${yesNo(link.approvalRequired)}`, + `Number is link admin\t${yesNo(link.isAdmin)}`, ].join('\n') } - -function metadata(value: { - readonly apiVersion?: string - readonly attempts: number - readonly requestId?: string - readonly status: number -}): CallsResult['metadata'] { - return { - attempts: value.attempts, - status: value.status, - ...(value.apiVersion === undefined ? {} : {apiVersion: value.apiVersion}), - ...(value.requestId === undefined ? {} : {requestId: value.requestId}), - } -} diff --git a/src/services/calls.ts b/src/services/calls.ts index c6f5bfb..10af59d 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -147,41 +147,6 @@ export interface PlaceCallInput { readonly video?: boolean | undefined } -interface GroupCallsResource { - place( - input: ( - | {participants: readonly string[]; groupId?: never} - | {groupId: string; participants?: never} - ) & { - session?: string - video?: boolean - exclusive?: boolean - participant?: string - }, - ): ReturnType - ringParticipant( - callId: string, - input: {to: string}, - ): Promise<{data: SuccessResponse; metadata: ResponseMetadata}> -} - -function groupResource(value: unknown): GroupCallsResource { - if ( - value === null || - typeof value !== 'object' || - !('place' in value) || - typeof value.place !== 'function' || - !('ringParticipant' in value) || - typeof value.ringParticipant !== 'function' - ) { - throw new PolymorfaConfigurationError( - 'Group calls and participant re-ring require a published SDK version with these controls. This CLI build has not received that SDK release.', - 'sdk', - ) - } - return value as GroupCallsResource -} - export async function placeCall( client: MessagingClient, credential: CallsCredential, @@ -225,10 +190,10 @@ export async function placeCall( } const response = await callRequest(() => groupId - ? groupResource(client.voip).place({groupId, ...options}) + ? client.voip.place({groupId, ...options}) : typeof to === 'string' ? client.voip.place({to, ...options}) - : groupResource(client.voip).place({participants: to!, ...options}), + : client.voip.place({participants: to!, ...options}), ) return result(response.data.data, response.metadata) } @@ -322,7 +287,7 @@ export async function ringCallParticipant( validateCallId(callId) const to = validateCallTarget(target, 'target') const response = await callRequest(() => - groupResource(client.voip).ringParticipant(callId, {to}), + client.voip.ringParticipant(callId, {to}), ) return result(response.data, response.metadata) } @@ -395,7 +360,7 @@ export async function updateCallSettings( return result(response.data.data, response.metadata) } -function validateSession(session: string): string { +export function validateSession(session: string): string { const trimmed = session.trim() if (trimmed.length === 0 || trimmed.length > 128) { throw new PolymorfaConfigurationError( @@ -665,11 +630,11 @@ export function renderCallRetention(retention: CallRetention): string { ].join('\n') } -function yesNo(value: boolean): string { +export function yesNo(value: boolean): string { return value ? 'yes' : 'no' } -function result(data: T, metadata: ResponseMetadata): CallsResult { +export function result(data: T, metadata: ResponseMetadata): CallsResult { return { data, metadata: { @@ -1398,37 +1363,6 @@ interface SocialInput { readonly connectionId: string readonly participant?: string | undefined } -type SocialReply = {data: SuccessResponse; metadata: ResponseMetadata} -interface SocialCallsResource { - sendReaction( - callId: string, - input: { - connectionId: string - participant?: string - emoji: CallReactionEmoji - }, - ): Promise - setHandRaised( - callId: string, - input: {connectionId: string; participant?: string; raised: boolean}, - ): Promise -} -function socialResource(value: unknown): SocialCallsResource { - if ( - value === null || - typeof value !== 'object' || - !('sendReaction' in value) || - typeof value.sendReaction !== 'function' || - !('setHandRaised' in value) || - typeof value.setHandRaised !== 'function' - ) { - throw new PolymorfaConfigurationError( - 'Call reactions and hands require a published SDK version with these controls. This CLI build has not received that SDK release.', - 'sdk', - ) - } - return value as SocialCallsResource -} export async function reactToCall( client: MessagingClient, credential: CallsCredential, @@ -1443,7 +1377,7 @@ export async function reactToCall( 'Reaction must be 👍, ❤️, 😂, 😮, 😢, 🙏, or empty to clear.', 'emoji', ) - const resource = socialResource(client.voip) + const resource = client.voip const response = await callRequest(() => resource.sendReaction(callId, { connectionId, @@ -1463,7 +1397,7 @@ export async function setCallHand( const connectionId = validateConnectionId(input.connectionId) const participant = validateParticipant(credential, input.participant) const response = await callRequest(() => - socialResource(client.voip).setHandRaised(callId, { + client.voip.setHandRaised(callId, { connectionId, raised: input.raised, ...(participant === undefined ? {} : {participant}), diff --git a/test/commands/call-links.test.ts b/test/commands/call-links.test.ts index 51aead0..457d777 100644 --- a/test/commands/call-links.test.ts +++ b/test/commands/call-links.test.ts @@ -2,6 +2,8 @@ import {runCommand} from '@oclif/test' import {Readable} from 'node:stream' import {afterEach, describe, expect, test} from 'vitest' +import {parseCallLink} from '../../src/services/call-links.js' + import { startHttpServer, withEnvironment, @@ -198,6 +200,53 @@ describe('call link commands', () => { expect(server.requests).toHaveLength(0) }) + test('parses pasted links and refuses ambiguous input', () => { + expect( + parseCallLink( + `Join my call: HTTPS://Call.WhatsApp.com/Video/${TOKEN}?lang=en#x.`, + ), + ).toEqual({token: TOKEN, video: true}) + expect(parseCallLink(`https://call.whatsapp.com/voice/${TOKEN}/`)).toEqual({ + token: TOKEN, + video: false, + }) + expect(parseCallLink(` ${TOKEN}\n`)).toEqual({token: TOKEN}) + // The same link twice is one link. + const link = `https://call.whatsapp.com/voice/${TOKEN}` + expect(parseCallLink(`${link} ${link}`)).toEqual({ + token: TOKEN, + video: false, + }) + expect(() => + parseCallLink(`${link} https://call.whatsapp.com/video/${TOKEN}`), + ).toThrow('more than one call link') + for (const input of [ + 'video', + `http://call.whatsapp.com/voice/${TOKEN}`, + 'https://call.whatsapp.com/voice/short', + `https://call.whatsapp.com/voice/${TOKEN}extra$`, + ]) + expect(() => parseCallLink(input)).toThrow('Standard input must contain') + }) + + test('refuses an interactive terminal and empty input with link messages', async () => { + server = await startHttpServer({body: {success: true}}) + const terminal = Object.assign(Readable.from([]), {isTTY: true}) + const interactive = await withStdinStream(terminal, () => + apiKey(() => runCommand('calls link preview --session support')), + ) + const empty = await withStdin('', () => + apiKey(() => runCommand('calls link preview --session support')), + ) + + expect(exitCode(interactive.error)).toBe(2) + expect(interactive.stderr).toContain('Pipe the call link') + expect(exitCode(empty.error)).toBe(2) + expect(empty.stderr).toContain('one call link or token') + expect(empty.stderr).not.toContain('credential') + expect(server.requests).toHaveLength(0) + }) + async function apiKey(run: () => Promise): Promise { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( @@ -228,10 +277,17 @@ function exitCode(error: unknown): number | undefined { } async function withStdin(value: string, run: () => Promise): Promise { + return withStdinStream(Readable.from(value.length === 0 ? [] : [value]), run) +} + +async function withStdinStream( + stream: Readable, + run: () => Promise, +): Promise { const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin') Object.defineProperty(process, 'stdin', { configurable: true, - value: Readable.from([value]), + value: stream, }) try { return await run() diff --git a/test/commands/call-records.test.ts b/test/commands/call-records.test.ts index c19fd81..fcc7472 100644 --- a/test/commands/call-records.test.ts +++ b/test/commands/call-records.test.ts @@ -1,5 +1,4 @@ import {runCommand} from '@oclif/test' -import {Client} from '@polymorfa/sdk' import {afterEach, describe, expect, test} from 'vitest' import {renderCallDetail} from '../../src/services/call-analytics.js' import { @@ -11,9 +10,6 @@ import { const API_KEY = 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const PROJECT = '11111111-1111-4111-8111-111111111111' -const hasDetail = - 'retrieve' in - new Client({credential: {type: 'organizationApiKey', value: API_KEY}}).calls const record = { callId: 'call_1', projectId: PROJECT, @@ -85,59 +81,44 @@ describe('stored call record commands', () => { } expect(server.requests).toHaveLength(1) }) - test.skipIf(hasDetail)( - 'refuses detail on an older registry SDK before transport', - async () => { - server = await startHttpServer({body: {data: {}}}) - const result = await run('calls view call_1 --json') - expect(result.error).toBeDefined() - expect(result.stderr).toContain('published SDK version') - expect(server.requests).toHaveLength(0) - }, - ) - test.skipIf(!hasDetail)( - 'reads detail through the SDK and rejects an invalid shape', - async () => { - const detail = { - call: {...record, live: false}, - history: { - events: [ - { - eventId: 'event_1', - type: 'call.ended', - occurredAt: record.endedAt, - }, - ], - truncated: true, - }, - telemetry: {status: 'unknown'}, - participants: [], - connections: [], - appReports: {status: 'none'}, - } - server = await startHttpServer({body: {data: detail}}) - const result = await run(`calls view call_1 --project ${PROJECT} --json`) - expect(result.error).toBeUndefined() - expect(server.requests[0]?.url).toBe( - `/platform/calls/call_1?projectId=${PROJECT}`, - ) - expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual( - detail, - ) - await server.close() - server = await startHttpServer({body: {data: {call: {callId: 'call_1'}}}}) - const invalid = await run('calls view call_1 --json') - expect(invalid.error).toBeDefined() - expect(invalid.stderr).toContain('invalid call detail') - await server.close() - server = await startHttpServer({ - body: {data: {...detail, history: {events: [null], truncated: false}}}, - }) - const malformedHistory = await run('calls view call_1') - expect(malformedHistory.error).toBeDefined() - expect(malformedHistory.stderr).toContain('invalid call detail') - }, - ) + test('reads detail through the SDK and rejects an invalid shape', async () => { + const detail = { + call: {...record, live: false}, + history: { + events: [ + { + eventId: 'event_1', + type: 'call.ended', + occurredAt: record.endedAt, + }, + ], + truncated: true, + }, + telemetry: {status: 'unknown'}, + participants: [], + connections: [], + appReports: {status: 'none'}, + } + server = await startHttpServer({body: {data: detail}}) + const result = await run(`calls view call_1 --project ${PROJECT} --json`) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + `/platform/calls/call_1?projectId=${PROJECT}`, + ) + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual(detail) + await server.close() + server = await startHttpServer({body: {data: {call: {callId: 'call_1'}}}}) + const invalid = await run('calls view call_1 --json') + expect(invalid.error).toBeDefined() + expect(invalid.stderr).toContain('invalid call detail') + await server.close() + server = await startHttpServer({ + body: {data: {...detail, history: {events: [null], truncated: false}}}, + }) + const malformedHistory = await run('calls view call_1') + expect(malformedHistory.error).toBeDefined() + expect(malformedHistory.stderr).toContain('invalid call detail') + }) test('renders bounded history without terminal control characters', () => { expect( renderCallDetail({ diff --git a/test/commands/calls-social.test.ts b/test/commands/calls-social.test.ts index 5976daa..22ba0dc 100644 --- a/test/commands/calls-social.test.ts +++ b/test/commands/calls-social.test.ts @@ -51,13 +51,7 @@ describe('call reaction and hand commands', () => { ).rejects.toThrow('Client tokens') expect(sendReaction).toHaveBeenCalledTimes(2) }) - test('refuses an older published SDK explicitly and never retries uncertain sends', async () => { - await expect( - reactToCall({voip: {}} as MessagingClient, credential, 'call-1', { - connectionId: 'connection-1', - emoji: '👍', - }), - ).rejects.toThrow('published SDK version') + test('never retries uncertain sends', async () => { const sendReaction = vi.fn(() => Promise.reject(new Error('lost after send')), ) diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index f9bb6e2..f60ca33 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -1,5 +1,4 @@ import {runCommand} from '@oclif/test' -import {MessagingClient} from '@polymorfa/sdk' import {afterEach, describe, expect, test} from 'vitest' import { @@ -32,12 +31,7 @@ describe('unified calls commands', () => { server = undefined }) - test.skipIf( - !( - 'ringParticipant' in - new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip - ), - )('places one group request and rings an existing participant', async () => { + test('places one group request and rings an existing participant', async () => { server = await startHttpSequenceServer([ { body: { @@ -71,35 +65,7 @@ describe('unified calls commands', () => { expect(server.requests).toHaveLength(2) }) - test.skipIf( - 'ringParticipant' in - new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip, - )( - 'refuses group controls with an older installed SDK before transport', - async () => { - server = await startHttpServer({body: {success: true}}) - for (const command of [ - 'calls place-group --to +15550100 --to +15550101 --session support --json', - 'calls place-group --group-id 9007199254740996 --session support --json', - 'calls ring call_1 +15550100 --json', - 'calls react call_1 --connection-id connection-1 --emoji 👍 --json', - 'calls hand call_1 --connection-id connection-1 --raised --json', - ]) { - const response = await apiKey(() => runCommand(command)) - expect(response.error).toBeDefined() - expect(`${response.stderr} ${response.error}`).toContain( - 'published SDK version', - ) - } - expect(server.requests).toHaveLength(0) - }, - ) - test.skipIf( - !( - 'ringParticipant' in - new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip - ), - )('places a stored group by public ID', async () => { + test('places a stored group by public ID', async () => { server = await startHttpServer({ body: { success: true, @@ -149,42 +115,34 @@ describe('unified calls commands', () => { expect(server.requests).toHaveLength(0) }) - test.skipIf( - !( - 'sendReaction' in - new MessagingClient({credential: {type: 'apiKey', value: API_KEY}}).voip - ), - )( - 'sends call reactions and hand state through a locally built SDK', - async () => { - server = await startHttpServer({body: {success: true}, status: 202}) - const reaction = await apiKey(() => - runCommand( - 'calls react call_1 --connection-id connection-1 --emoji 👍 --participant agent --json', - ), - ) - expect(reaction.error).toBeUndefined() - expect(server.requests[0]?.url).toBe( - '/messaging/voip/calls/call_1/reaction', - ) - expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ - connectionId: 'connection-1', - emoji: '👍', - participant: 'agent', - }) - const hand = await apiKey(() => - runCommand( - 'calls hand call_1 --connection-id connection-1 --raised --json', - ), - ) - expect(hand.error).toBeUndefined() - expect(server.requests[1]?.url).toBe('/messaging/voip/calls/call_1/hand') - expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ - connectionId: 'connection-1', - raised: true, - }) - }, - ) + test('sends call reactions and hand state through a locally built SDK', async () => { + server = await startHttpServer({body: {success: true}, status: 202}) + const reaction = await apiKey(() => + runCommand( + 'calls react call_1 --connection-id connection-1 --emoji 👍 --participant agent --json', + ), + ) + expect(reaction.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/messaging/voip/calls/call_1/reaction', + ) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + connectionId: 'connection-1', + emoji: '👍', + participant: 'agent', + }) + const hand = await apiKey(() => + runCommand( + 'calls hand call_1 --connection-id connection-1 --raised --json', + ), + ) + expect(hand.error).toBeUndefined() + expect(server.requests[1]?.url).toBe('/messaging/voip/calls/call_1/hand') + expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + connectionId: 'connection-1', + raised: true, + }) + }) test('places a call from a named session with a claim and participant', async () => { server = await startHttpServer({