diff --git a/README.md b/README.md index 49a22a4..201b95f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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: @@ -2517,3 +2517,27 @@ polymorfa billing priority reorder --scope resource --project --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. diff --git a/package-lock.json b/package-lock.json index a1a2b55..a0a78fb 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.20261003052138", + "@polymorfa/sdk": "0.1.0-dev.20261004011426", "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.20261003052138", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261003052138.tgz", - "integrity": "sha512-NJLO6WcbDgCzmEiFiuQhSDdrF9cCuuVtKVH1CgmyH27yFQ/mFW62TK/p2lOHRVH5p45eyzX3r/7iCqSF2cOECA==", + "version": "0.1.0-dev.20261004011426", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004011426.tgz", + "integrity": "sha512-1bC03PyxMpzlRtIFBHfk6OQhcyqsgGKHfWrShq+0pN/0i+w0JRw3JoGo6UC1NdBKmsRIZtsRqs8cpYxsacbQFA==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 6d095e4..6283acc 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.20261003052138", + "@polymorfa/sdk": "0.1.0-dev.20261004011426", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -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." } } }, diff --git a/src/commands/analytics/get.ts b/src/commands/analytics/get.ts new file mode 100644 index 0000000..089fdc5 --- /dev/null +++ b/src/commands/analytics/get.ts @@ -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 { + 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) + } +} diff --git a/src/commands/analytics/metrics.ts b/src/commands/analytics/metrics.ts new file mode 100644 index 0000000..c232297 --- /dev/null +++ b/src/commands/analytics/metrics.ts @@ -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 { + 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) + } +} diff --git a/src/services/analytics.ts b/src/services/analytics.ts new file mode 100644 index 0000000..6d0c629 --- /dev/null +++ b/src/services/analytics.ts @@ -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, + 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, + query: AnalyticsMetricsParams, +) { + return client.analytics.metrics(query) +} diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts new file mode 100644 index 0000000..616f73a --- /dev/null +++ b/test/commands/analytics.test.ts @@ -0,0 +1,212 @@ +import {runCommand} from '@oclif/test' +import {afterEach, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' +const servers: TestHttpServer[] = [] +afterEach(async () => { + await Promise.all(servers.splice(0).map((s) => s.close())) +}) +const PROJECT = '22222222-2222-4222-8222-222222222222' +const intervals = { + completedPhoneActivityPeriods: 2, + phoneActivityMs: 600_000, + averagePhoneActivityMs: 300_000, + phoneQuietGaps: 1, + phoneQuietMs: 1_200_000, + averagePhoneQuietMs: 1_200_000, +} +const body = { + enabled: false, + period: {start: 0, end: 1}, + requestVitals: {requests: 20, failures: 1, errorRate: 0.05}, + summary: null, + numbers: [], + series: [], + callSeries: [], +} +test('runs the JSON analytics command against the project-confined public route', async () => { + const server = await startHttpServer({body: {data: body}}) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => + runCommand([ + 'analytics', + 'get', + '--project', + PROJECT, + '--since', + '2026-10-01T00:00:00Z', + '--until', + '2026-10-03T00:00:00Z', + '--json', + ]), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(result.stdout) as unknown).toMatchObject({ + data: {enabled: false}, + }) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.url).toContain( + `/platform/projects/${PROJECT}/analytics`, + ) + expect(server.requests[0]?.url).toContain( + `start=${Date.parse('2026-10-01T00:00:00Z')}`, + ) +}) +test('rejects an invalid range before making a request', async () => { + const server = await startHttpServer({body: {data: body}}) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => + runCommand([ + 'analytics', + 'get', + '--since', + '2026-10-03T00:00:00Z', + '--until', + '2026-10-01T00:00:00Z', + ]), + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) +}) + +test('shows call vitals when messaging measurements are unavailable', async () => { + const calls = { + total: 3, + answerRate: 0.5, + directions: {inbound: {missed: 1}, outbound: {missed: 0}}, + failed: 1, + medianTalkSeconds: 60, + averagePickupMs: 3000, + followUp: { + rate: 0.5, + returnedWithin24h: 1, + eligibleMissed: 2, + pendingWindow: 1, + }, + } + const server = await startHttpServer({ + body: { + data: { + ...body, + enabled: true, + summary: { + accountActivity: intervals, + measured: false, + calls, + }, + callSeries: [{ts: 1, answered: 1}], + }, + }, + }) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(['analytics', 'get']), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain('Calls: 3') + expect(result.stdout).toContain('Call answer rate: 50.0%') + expect(result.stdout).toContain('Missed incoming calls: 1') + expect(result.stdout).toContain('No message measurements') + + const json = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(['analytics', 'get', '--json']), + ) + expect(json.error, json.stderr).toBeUndefined() + expect(JSON.parse(json.stdout) as unknown).toMatchObject({ + data: {summary: {accountActivity: intervals}}, + }) +}) + +test('exports raw collector text through the project-confined SDK transport', async () => { + const rawBody = + '# TYPE polymorfa_analytics_enabled gauge\npolymorfa_analytics_enabled{scope="project"} 0\n# EOF\n' + const server = await startHttpServer({ + rawBody, + headers: { + 'content-type': + 'application/openmetrics-text; version=1.0.0; charset=utf-8', + }, + }) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => + runCommand([ + 'analytics', + 'metrics', + '--project', + PROJECT, + '--format', + 'openmetrics', + '--segments', + '--window-hours', + '6', + ]), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toBe(rawBody) + expect(server.requests[0]?.url).toContain( + `/platform/projects/${PROJECT}/analytics/metrics`, + ) + expect(server.requests[0]?.url).toContain('windowHours=6') + expect(server.requests[0]?.url).toContain('segments=true') +}) + +test('rejects an HTML response instead of printing it as collector data', async () => { + const server = await startHttpServer({ + rawBody: '

Proxy failure

', + headers: {'content-type': 'text/html'}, + }) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(['analytics', 'metrics']), + ) + expect(result.error).toBeDefined() + expect(result.stdout).not.toContain('Proxy failure') +})