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
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ prerelease in `package.json`.
The version output includes both runtime components:

```text
polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261003052138
polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261004011426
```

The SDK component reports the version built into the installed SDK. Dev
Expand Down Expand Up @@ -2316,14 +2316,14 @@ Business Platform > Disconnect Account. An ineligible Number or pair fails with
`hybrid_transition_ineligible` (exit code 4) and the API's reason in the message.
Merging requires Hybrid Link access, which the API enforces.

These commands use published SDK `0.1.0-dev.20261003052138`, which pins API
contract `ee885ec4bf62af36b9b85df4310c505f38a224db`.
These commands use published SDK `0.1.0-dev.20261004011426`, which pins API
contract `171a9683ff45d11a4b5d3917dc428ad2722b0d99`.

### Hybrid Link controls

These additions require a CLI release containing the Hybrid Link commands.
They use published SDK `0.1.0-dev.20261003052138`, which pins API contract
`ee885ec4bf62af36b9b85df4310c505f38a224db`. The SDK defaults native requests
They use published SDK `0.1.0-dev.20261004011426`, which pins API contract
`171a9683ff45d11a4b5d3917dc428ad2722b0d99`. The SDK defaults native requests
to API revision `2026-09-22`. An
explicit `--api-version` or saved-profile version remains explicit; retired
versions fail without an automatic upgrade or resend.
Expand Down Expand Up @@ -2480,7 +2480,7 @@ Project and client credentials cannot use these financial controls. Commands
use the pinned SDK's authenticated raw transport; no unpublished SDK method is
required. CLI publication and API deployment remain separate release gates.
This source follows the financial-control contract at API commit
`ba09326dd3c6688f05320c1f7685aeb0290a0ed2`.
`171a9683ff45d11a4b5d3917dc428ad2722b0d99`.

Read or update one resource without loading the team directory:

Expand Down Expand Up @@ -2517,3 +2517,27 @@ polymorfa billing priority reorder --scope resource --project <project-uuid> --i
```

Use either `--ids` or `--ids-file`. Funding order never bypasses a spending cap.

### WhatsApp Analytics

Read message and call outcomes, response times, number activity and connection
vitals with a credential carrying `sessions:read`. Enable Analytics in the
Console to collect business data. Project credentials stay within their project.

```bash
polymorfa analytics get --project PROJECT_UUID --json
polymorfa analytics get --project PROJECT_UUID --session-id NUMBER_UUID --since 2026-10-01T00:00:00Z --until 2026-10-03T00:00:00Z --json
polymorfa analytics metrics --project PROJECT_UUID --window-hours 24 > analytics.prom
polymorfa analytics metrics --project PROJECT_UUID --segments --format openmetrics
```

JSON reads default to the preceding 30 days. Metrics cover 1–168 completed UTC
hours, defaulting to 24. They are overlapping-window gauges; do not apply
`rate()` or `increase()`. Read and reply rates use completed 24-hour conversation
windows. Null rates indicate no denominator or incomplete coverage. Phone
activity spans and quiet gaps describe observed signals, not device power state.
Disabled Analytics returns no business aggregates.

These commands use the pinned SDK's `analytics.get` and `analytics.metrics`
methods and shared response types. A CLI release containing these commands and
an API deployment exposing Analytics are separate requirements.
8 changes: 4 additions & 4 deletions package-lock.json

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

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@modelcontextprotocol/server": "2.0.0",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20261003052138",
"@polymorfa/sdk": "0.1.0-dev.20261004011426",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down Expand Up @@ -326,6 +326,9 @@
},
"cloud:group:join-requests": {
"description": "Review join requests for Official groups that require approval."
},
"analytics": {
"description": "Read WhatsApp number activity and operational vitals."
}
}
},
Expand Down
43 changes: 43 additions & 0 deletions src/commands/analytics/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {Flags} from '@oclif/core'
import {BaseCommand} from '../../base-command.js'
import {
analyticsQuery,
getAnalytics,
renderAnalytics,
} from '../../services/analytics.js'
export default class AnalyticsGet extends BaseCommand {
static override description =
'Read WhatsApp number activity, completed 24-hour read and reply rates, response times, call outcomes and connection vitals. Requires sessions:read and Analytics enabled in the Console. Project credentials read only their project.'
static override summary = 'Read WhatsApp number analytics.'
static override examples = [
'<%= config.bin %> analytics get --project PROJECT_ID --json',
'<%= config.bin %> analytics get --since 2026-10-01T00:00:00Z --until 2026-10-03T00:00:00Z',
]
static override flags = {
since: Flags.string({
description: 'Start time in RFC 3339; defaults to 30 days ago.',
}),
until: Flags.string({
description: 'End time in RFC 3339; defaults to now.',
}),
'session-id': Flags.string({
description: 'Limit to one number record UUID.',
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(AnalyticsGet)
const query = analyticsQuery({
...(flags.since === undefined ? {} : {since: flags.since}),
...(flags.until === undefined ? {} : {until: flags.until}),
...(flags['session-id'] === undefined
? {}
: {sessionId: flags['session-id']}),
})
const session = await this.developerSession({
...flags,
owner: flags.owner ?? (flags.project ? 'project' : 'organization'),
})
const result = await getAnalytics(session.client, query)
this.writeValue(result, renderAnalytics(result.data), flags)
}
}
50 changes: 50 additions & 0 deletions src/commands/analytics/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {Flags} from '@oclif/core'
import {BaseCommand} from '../../base-command.js'
import {
analyticsMetricsQuery,
getAnalyticsMetrics,
} from '../../services/analytics.js'

export default class AnalyticsMetrics extends BaseCommand {
static override summary = 'Export collector-compatible analytics metrics.'
static override description =
'Write Prometheus or OpenMetrics gauges to stdout. Requires sessions:read and Analytics enabled in the Console. Counts cover completed UTC hours and are not monotonic counters. Project credentials read only their project. Disabled Analytics returns only enablement and window metadata. Use --json for a text body and response metadata envelope.'
static override examples = [
'<%= config.bin %> analytics metrics --project PROJECT_ID > analytics.prom',
'<%= config.bin %> analytics metrics --window-hours 24 --segments --format openmetrics',
]
static override flags = {
'session-id': Flags.string({
description: 'Limit to one number record UUID.',
}),
'window-hours': Flags.integer({
description: 'Completed UTC hours to measure.',
default: 24,
min: 1,
max: 168,
}),
segments: Flags.boolean({
description: 'Include bounded metadata category breakdowns.',
}),
format: Flags.string({
description: 'Metrics representation.',
options: ['prometheus', 'openmetrics'],
default: 'prometheus',
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(AnalyticsMetrics)
const query = analyticsMetricsQuery({
sessionId: flags['session-id'],
windowHours: flags['window-hours'],
segments: flags.segments,
format: flags.format,
})
const session = await this.developerSession({
...flags,
owner: flags.owner ?? (flags.project ? 'project' : 'organization'),
})
const result = await getAnalyticsMetrics(session.client, query)
this.writeValue(result, result.data.trimEnd(), flags)
}
}
147 changes: 147 additions & 0 deletions src/services/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
PolymorfaServerError,
PolymorfaValidationError,
type ClientBase,
type ClientOwner,
type AnalyticsParams,
type AnalyticsMetricsParams,
type WhatsAppAnalytics,
} from '@polymorfa/sdk'
export type {WhatsAppAnalytics} from '@polymorfa/sdk'
import {optionalTimeRange} from '../input/time.js'
export interface AnalyticsSettings {
enabled: boolean
}

export function analyticsQuery(input: {
since?: string
until?: string
sessionId?: string
}): AnalyticsParams {
optionalTimeRange(input.since, input.until)
const query: {start?: number; end?: number; sessionId?: string} = {}
if (input.since !== undefined) query.start = Date.parse(input.since)
if (input.until !== undefined) query.end = Date.parse(input.until)
if (
input.since !== undefined &&
input.until !== undefined &&
Date.parse(input.until) - Date.parse(input.since) > 366 * 86_400_000
)
throw new PolymorfaValidationError('Choose a range of up to 366 days.', {
code: 'invalid_analytics_range',
})
if (input.sessionId !== undefined) {
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
input.sessionId,
)
)
throw new PolymorfaValidationError('--session-id must be a UUID.', {
code: 'invalid_analytics_filter',
})
query.sessionId = input.sessionId
}
return query
}
export async function getAnalytics(
client: ClientBase<ClientOwner>,
query: AnalyticsParams,
) {
const response = await client.analytics.get(query)
const data = response.data
if (
!data ||
typeof data.enabled !== 'boolean' ||
!data.requestVitals ||
!Array.isArray(data.numbers) ||
!Array.isArray(data.series) ||
!Array.isArray(data.callSeries) ||
(data.enabled &&
(!data.summary ||
typeof data.summary.measured !== 'boolean' ||
!data.summary.calls ||
!Number.isSafeInteger(data.summary.calls.total) ||
!data.summary.calls.directions?.inbound ||
!data.summary.calls.directions?.outbound))
)
throw new PolymorfaServerError('The API returned invalid analytics.', {
code: 'invalid_response',
})
return {
data,
metadata: {
status: response.metadata.status,
attempts: response.metadata.attempts,
...(response.metadata.requestId === undefined
? {}
: {requestId: response.metadata.requestId}),
},
}
}
export function renderAnalytics(data: WhatsAppAnalytics) {
const rate = (v: number | null) =>
v === null ? 'Not measured' : `${(v * 100).toFixed(1)}%`
const requests = `API errors: ${rate(data.requestVitals.errorRate)} (${data.requestVitals.failures}/${data.requestVitals.requests})`
if (!data.enabled)
return `Analytics is disabled. A team owner or admin can enable it in the Console.\n${requests}`
const m = data.summary!
return [
...(m.measured
? [
`Sent: ${m.outgoingMessages}`,
`Received (direct): ${m.incomingMessages}`,
`Read rate (24h): ${rate(m.engagement.readRate)}`,
`Reply rate (24h): ${rate(m.engagement.replyRate)}`,
`Send failure rate: ${rate(m.sendFailureRate)}`,
`Disconnects: ${m.disconnects}`,
`Connection failures: ${m.connectFailures}`,
]
: ['No message measurements in this period.']),
`Calls: ${m.calls.total}`,
`Call answer rate: ${rate(m.calls.answerRate)}`,
`Missed incoming calls: ${m.calls.directions.inbound.missed}`,
`Failed calls: ${m.calls.failed}`,
`Median call length: ${m.calls.medianTalkSeconds === null ? 'Not measured' : `${m.calls.medianTalkSeconds} s`}`,
`Average pickup: ${m.calls.averagePickupMs === null ? 'Not measured' : `${(m.calls.averagePickupMs / 1000).toFixed(1)} s`}`,
`Missed-call follow-up (24h): ${rate(m.calls.followUp.rate)} (${m.calls.followUp.returnedWithin24h}/${m.calls.followUp.eligibleMissed}; ${m.calls.followUp.pendingWindow} pending)`,
requests,
].join('\n')
}

export function analyticsMetricsQuery(input: {
sessionId?: string | undefined
windowHours?: number | undefined
segments?: boolean | undefined
format?: string | undefined
}): AnalyticsMetricsParams {
const query = analyticsQuery(
input.sessionId === undefined ? {} : {sessionId: input.sessionId},
)
if (
input.windowHours !== undefined &&
(!Number.isInteger(input.windowHours) ||
input.windowHours < 1 ||
input.windowHours > 168)
)
throw new PolymorfaValidationError(
'Choose a metrics window of 1 to 168 hours.',
{code: 'invalid_analytics_range'},
)
const format = input.format ?? 'prometheus'
if (format !== 'prometheus' && format !== 'openmetrics')
throw new PolymorfaValidationError('Choose prometheus or openmetrics.', {
code: 'invalid_analytics_filter',
})
return {
...query,
windowHours: input.windowHours ?? 24,
segments: input.segments ?? false,
format,
}
}
export function getAnalyticsMetrics(
client: ClientBase<ClientOwner>,
query: AnalyticsMetricsParams,
) {
return client.analytics.metrics(query)
}
Loading