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
47 changes: 29 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
36 changes: 36 additions & 0 deletions src/commands/calls/link/create.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
69 changes: 69 additions & 0 deletions src/commands/calls/link/preview.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string> {
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
}
}
2 changes: 1 addition & 1 deletion src/commands/calls/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'}),
}
Expand Down
99 changes: 99 additions & 0 deletions src/services/call-links.ts
Original file line number Diff line number Diff line change
@@ -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<string, ParsedCallLink>()
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<CallsResult<VoipCreatedCallLink>> {
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<CallsResult<VoipPreviewedCallLink>> {
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')
}
Loading