diff --git a/README.md b/README.md index 50dea38..e833f0e 100644 --- a/README.md +++ b/README.md @@ -976,9 +976,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 @@ -2344,16 +2343,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 @@ -2368,9 +2382,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/package.json b/package.json index 37ce93d..b052f4d 100644 --- a/package.json +++ b/package.json @@ -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..aff86ce --- /dev/null +++ b/src/commands/calls/link/preview.ts @@ -0,0 +1,69 @@ +import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +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 readCallLinkFromStdin() + 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) + } +} + +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/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-links.ts b/src/services/call-links.ts new file mode 100644 index 0000000..e913b52 --- /dev/null +++ b/src/services/call-links.ts @@ -0,0 +1,99 @@ +import { + type MessagingClient, + PolymorfaValidationError, + type VoipCreatedCallLink, + type VoipPreviewedCallLink, +} from '@polymorfa/sdk' + +import {type CallsResult, result, validateSession, yesNo} from './calls.js' + +// 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_-]{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 + /** Set when the input was a full link, whose path names the media type. */ + readonly video?: boolean +} + +/** Accept one WhatsApp call link, possibly inside share text, or its token. */ +export function parseCallLink(input: string): ParsedCallLink { + const value = input.trim() + 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.', + ) +} + +export async function createCallLink( + client: MessagingClient, + input: {readonly session: string; readonly video?: boolean}, +): Promise> { + const response = await client.voip.createCallLink({ + session: validateSession(input.session), + video: input.video === true, + }) + return result(response.data.data, 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 pipe the matching link.`, + ) + } + const response = await client.voip.previewCallLink({ + session: validateSession(input.session), + token: link.token, + video: link.video ?? input.video === true, + }) + return result(response.data.data, response.metadata) +} + +export function renderCreatedCallLink(link: VoipCreatedCallLink): string { + return [ + `Link\t${link.url}`, + `Session\t${link.session}`, + `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') +} + +export function renderPreviewedCallLink(link: VoipPreviewedCallLink): string { + const creator = + link.creator.phoneNumber ?? link.creator.username ?? link.creator.id + return [ + `Session\t${link.session}`, + `Video\t${yesNo(link.video)}`, + `Creator\t${creator}`, + `Approval required\t${yesNo(link.approvalRequired)}`, + `Number is link admin\t${yesNo(link.isAdmin)}`, + ].join('\n') +} 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 new file mode 100644 index 0000000..457d777 --- /dev/null +++ b/test/commands/call-links.test.ts @@ -0,0 +1,298 @@ +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, + 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) + }) + + 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( + { + 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 { + 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: stream, + }) + try { + return await run() + } finally { + if (descriptor !== undefined) + Object.defineProperty(process, 'stdin', descriptor) + } +} 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({