diff --git a/README.md b/README.md index efbdc3c2..a6f427dd 100644 --- a/README.md +++ b/README.md @@ -1918,6 +1918,11 @@ polymorfa webhook create \ --input webhook.json \ --secret-out ./webhook.secret +polymorfa webhook update webhook_123 \ + --owner project \ + --project project_123 \ + --phone-offline-start-day 8 + polymorfa webhook test webhook_123 \ --owner project \ --project project_123 \ @@ -1933,6 +1938,14 @@ polymorfa webhook delivery attempt list delivery_123 \ --project project_123 ``` +`--phone-offline-start-day` on `webhook create` and `webhook update` sets the +first day without the phone, from `7` to `13`, on which the webhook receives +`session.phone_offline`. The API default is `10`. `off` removes that event from +the webhook's event types; `webhook update` reads the saved event types when +`--input` has none, and refuses `off` when it would leave no event. `webhook +update` accepts the flag without `--input`. See +[Phone offline warnings](https://docs.polymorfa.com/api/webhooks#phone-offline-warnings). + `webhook test` requires `--event`. Team tests accept that event type only. Project tests can also read a JSON object with `body` and `sessionId` from `--input`; supply both fields together. The CLI rejects a team body or an diff --git a/src/commands/webhook/create.ts b/src/commands/webhook/create.ts index b2ff9642..bcca62f2 100644 --- a/src/commands/webhook/create.ts +++ b/src/commands/webhook/create.ts @@ -9,6 +9,10 @@ import {prepareOneTimeSecretDelivery} from '../../developer/secrets.js' import {record, stringField} from '../../developer/support.js' import {CliConditionError} from '../../errors/conditions.js' import {idempotencyKey} from '../../input/idempotency.js' +import { + phoneOfflineStartDayFlag, + withPhoneOfflineStartDay, +} from '../../developer/phone-offline.js' export default class WebhookCreate extends BaseCommand { static override description = 'Create a webhook from one JSON object.' @@ -19,6 +23,7 @@ export default class WebhookCreate extends BaseCommand { description: 'Read the webhook JSON object from a file or stdin (-).', required: true, }), + 'phone-offline-start-day': phoneOfflineStartDayFlag, 'secret-out': Flags.string({ description: 'Write the one-time signing secret to a new mode-0600 file.', }), @@ -30,11 +35,20 @@ export default class WebhookCreate extends BaseCommand { json: flags.json === true, ...(flags['secret-out'] === undefined ? {} : {path: flags['secret-out']}), }) + const body = record(await readJsonInput(flags.input), 'Webhook input') + let merged: Readonly> = body + if (flags['phone-offline-start-day'] !== undefined) { + try { + merged = withPhoneOfflineStartDay( + body, + flags['phone-offline-start-day'], + ) + } catch (error) { + this.error((error as Error).message, {exit: 2}) + } + } const session = await this.developerSession(flags) - const input = record( - await readJsonInput(flags.input), - 'Webhook input', - ) as unknown as CreateProjectWebhookInput + const input = merged as unknown as CreateProjectWebhookInput const response = await session.client.webhooks.create(input, { ...session.requestOptions, idempotencyKey: idempotencyKey(flags['idempotency-key']), diff --git a/src/commands/webhook/update.ts b/src/commands/webhook/update.ts index 3bfb9b46..4d4f6de7 100644 --- a/src/commands/webhook/update.ts +++ b/src/commands/webhook/update.ts @@ -6,6 +6,10 @@ import {idempotencyFlag} from '../../developer/flags.js' import {developerMutation} from '../../developer/output.js' import {record, stringField} from '../../developer/support.js' import {idempotencyKey} from '../../input/idempotency.js' +import { + phoneOfflineStartDayFlag, + withPhoneOfflineStartDay, +} from '../../developer/phone-offline.js' export default class WebhookUpdate extends BaseCommand { static override args = { @@ -18,22 +22,43 @@ export default class WebhookUpdate extends BaseCommand { 'idempotency-key': idempotencyFlag, input: Flags.string({ description: - 'Read the webhook update JSON object from a file or stdin (-).', - required: true, + 'Read the webhook update JSON object from a file or stdin (-). Optional with --phone-offline-start-day.', }), + 'phone-offline-start-day': phoneOfflineStartDayFlag, } async run(): Promise { const {args, flags} = await this.parse(WebhookUpdate) + const startDay = flags['phone-offline-start-day'] + if (flags.input === undefined && startDay === undefined) { + this.error('Provide --input or --phone-offline-start-day.', {exit: 2}) + } + let body: Readonly> = + flags.input === undefined + ? {} + : record(await readJsonInput(flags.input), 'Webhook input') const session = await this.developerSession(flags) - const response = await session.client.webhooks.update( - args.webhook, - record(await readJsonInput(flags.input), 'Webhook input'), - { - ...session.requestOptions, - idempotencyKey: idempotencyKey(flags['idempotency-key']), - }, - ) + if (startDay !== undefined) { + // Off edits the saved event list unless the input already supplies one. + const saved = + startDay === 'off' && !Array.isArray(body.eventTypes) + ? ( + await session.client.webhooks.retrieve( + args.webhook, + session.requestOptions, + ) + ).data.eventTypes + : undefined + try { + body = withPhoneOfflineStartDay(body, startDay, saved) + } catch (error) { + this.error((error as Error).message, {exit: 2}) + } + } + const response = await session.client.webhooks.update(args.webhook, body, { + ...session.requestOptions, + idempotencyKey: idempotencyKey(flags['idempotency-key']), + }) const receipt = record(response.data, 'Webhook mutation receipt') const webhook = record(receipt.webhook, 'Webhook') const output = developerMutation({ diff --git a/src/developer/phone-offline.ts b/src/developer/phone-offline.ts new file mode 100644 index 00000000..1e7ca3c3 --- /dev/null +++ b/src/developer/phone-offline.ts @@ -0,0 +1,48 @@ +import {Flags} from '@oclif/core' + +export const PHONE_OFFLINE_EVENT = 'session.phone_offline' +const MIN_DAY = 7 +const MAX_DAY = 13 + +/** A day from 7 to 13, or off to unsubscribe from the warning. */ +export type PhoneOfflineStartDay = number | 'off' + +function parseStartDay(value: string): PhoneOfflineStartDay { + if (value === 'off') return 'off' + const day = Number(value) + if (!/^\d+$/.test(value) || day < MIN_DAY || day > MAX_DAY) { + throw new Error(`Expected a day from ${MIN_DAY} to ${MAX_DAY}, or off.`) + } + return day +} + +export const phoneOfflineStartDayFlag = Flags.custom({ + description: `First day without the phone on which the webhook receives ${PHONE_OFFLINE_EVENT} (${MIN_DAY}-${MAX_DAY}), or off to remove that event. WhatsApp signs the device out on day 14.`, + parse: (value) => Promise.resolve(parseStartDay(value)), +})() + +/** + * Merge the flag into a webhook body. A day sets phoneOfflineStartDay; off + * removes the event from eventTypes, read from the body or else the saved + * webhook. Platform webhooks need at least one event, so off cannot empty it. + */ +export function withPhoneOfflineStartDay( + body: Readonly>, + value: PhoneOfflineStartDay, + savedEventTypes?: readonly string[], +): Record { + if (value !== 'off') return {...body, phoneOfflineStartDay: value} + const eventTypes = Array.isArray(body.eventTypes) + ? (body.eventTypes as unknown[]).map(String) + : savedEventTypes + if (eventTypes === undefined) { + throw new Error('Add eventTypes to the input before using off.') + } + const remaining = eventTypes.filter((event) => event !== PHONE_OFFLINE_EVENT) + if (remaining.length === 0) { + throw new Error( + `A webhook needs at least one event besides ${PHONE_OFFLINE_EVENT}. Delete or disable the webhook instead.`, + ) + } + return {...body, eventTypes: remaining} +} diff --git a/src/developer/render.ts b/src/developer/render.ts index c3413261..e1526fac 100644 --- a/src/developer/render.ts +++ b/src/developer/render.ts @@ -35,6 +35,17 @@ export function renderEventDetail( return table(rows) } +/** Off when the explicit event list leaves the warning out. */ +function phoneOfflineWarning(webhook: { + readonly eventTypes: readonly string[] + readonly phoneOfflineStartDay?: number +}): string { + if (!webhook.eventTypes.includes('session.phone_offline')) return 'off' + return webhook.phoneOfflineStartDay === undefined + ? 'not reported' + : `from day ${webhook.phoneOfflineStartDay}` +} + export function renderWebhookDetail(webhook: { readonly id: string readonly owner: string @@ -44,6 +55,7 @@ export function renderWebhookDetail(webhook: { readonly format: string readonly eventTypes: readonly string[] readonly headers: readonly {readonly name: string}[] + readonly phoneOfflineStartDay?: number readonly createdAt: string readonly updatedAt: string }): string { @@ -55,6 +67,7 @@ export function renderWebhookDetail(webhook: { ['Enabled', webhook.enabled], ['Format', webhook.format], ['Event types', webhook.eventTypes.join(', ')], + ['Phone offline warning', phoneOfflineWarning(webhook)], ['Header names', webhook.headers.map(({name}) => name).join(', ')], ['Created', webhook.createdAt], ['Updated', webhook.updatedAt], diff --git a/test/commands/developer-webhooks.test.ts b/test/commands/developer-webhooks.test.ts index 35c368ac..b79c1e4c 100644 --- a/test/commands/developer-webhooks.test.ts +++ b/test/commands/developer-webhooks.test.ts @@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({ createSecretAvailable: true, listPages: [] as Array>, rotationSecretAvailable: true, + savedEventTypes: ['message.received'] as string[], })) vi.mock('@polymorfa/sdk', async (importOriginal) => { @@ -51,6 +52,7 @@ describe('developer webhook commands', () => { state.createSecretAvailable = true state.listPages.splice(0) state.rotationSecretAvailable = true + state.savedEventTypes = ['message.received'] }) afterEach(async () => { @@ -177,6 +179,7 @@ describe('developer webhook commands', () => { const text = output.mock.calls.map(([chunk]) => String(chunk)).join('') expect(text).toContain('URL\thttps://hooks.example.test/polymorfa') expect(text).toContain('Event types\tmessage.received') + expect(text).toContain('Phone offline warning\toff') expect(text).toContain('Status\tsucceeded') expect(text).toContain('Last status code\t204') }) @@ -366,6 +369,146 @@ describe('developer webhook commands', () => { }) }) + test('sets the phone offline start day on create', async () => { + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const input = join(directory, 'webhook.json') + await writeFile( + input, + JSON.stringify({ + eventTypes: ['session.phone_offline'], + url: 'https://example.test/hook', + }), + ) + stdout() + await developerEnvironment(() => + WebhookCreate.run([ + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--input', + input, + '--phone-offline-start-day', + '8', + '--secret-out', + join(directory, 'webhook.secret'), + '--idempotency-key', + 'create_1', + '--json', + ]), + ) + expect(findCall('webhooks.create').args[0]).toEqual({ + eventTypes: ['session.phone_offline'], + phoneOfflineStartDay: 8, + url: 'https://example.test/hook', + }) + }) + + test('updates only the start day without an input file', async () => { + stdout() + await developerEnvironment(() => + WebhookUpdate.run([ + 'wh_1', + '--owner', + 'organization', + '--phone-offline-start-day', + '13', + '--idempotency-key', + 'update_1', + '--json', + ]), + ) + expect(findCall('webhooks.update').args.slice(0, 2)).toEqual([ + 'wh_1', + {phoneOfflineStartDay: 13}, + ]) + }) + + test('turns warnings off by removing the event from the saved event types', async () => { + state.savedEventTypes = ['message.received', 'session.phone_offline'] + stdout() + await developerEnvironment(() => + WebhookUpdate.run([ + 'wh_1', + '--owner', + 'organization', + '--phone-offline-start-day', + 'off', + '--idempotency-key', + 'update_1', + '--json', + ]), + ) + expect(findCall('webhooks.retrieve').args[0]).toBe('wh_1') + expect(findCall('webhooks.update').args.slice(0, 2)).toEqual([ + 'wh_1', + {eventTypes: ['message.received']}, + ]) + }) + + test('refuses off when it would leave no events', async () => { + state.savedEventTypes = ['session.phone_offline'] + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + const error = await developerEnvironment(() => + WebhookUpdate.run([ + 'wh_1', + '--owner', + 'organization', + '--phone-offline-start-day', + 'off', + ]), + ).catch((caught: unknown) => caught) + expect(exitCode(error)).toBe(2) + expect(String(stderr.mock.calls.flat().join(''))).toContain( + 'at least one event', + ) + expect(state.calls.some(({method}) => method === 'webhooks.update')).toBe( + false, + ) + }) + + test.each(['6', '14', '9.5', 'never'])( + 'rejects --phone-offline-start-day %s before calling the SDK', + async (value) => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + const error = await developerEnvironment(() => + WebhookUpdate.run([ + 'wh_1', + '--owner', + 'organization', + '--phone-offline-start-day', + value, + ]), + ).catch((caught: unknown) => caught) + expect(exitCode(error)).not.toBe(0) + expect(String(stderr.mock.calls.flat().join(''))).toContain( + 'Expected a day from 7 to 13, or off.', + ) + expect( + state.calls.some(({method}) => method.startsWith('webhooks.')), + ).toBe(false) + }, + ) + + test('requires an input or a start day for updates', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + const error = await developerEnvironment(() => + WebhookUpdate.run(['wh_1', '--owner', 'organization']), + ).catch((caught: unknown) => caught) + expect(exitCode(error)).toBe(2) + expect(String(stderr.mock.calls.flat().join(''))).toContain( + 'Provide --input or --phone-offline-start-day.', + ) + expect(state.calls).toHaveLength(0) + }) + test('creates a test event and delivery without an extra confirmation', async () => { const output = stdout() @@ -660,7 +803,7 @@ function resource( response({ createdAt: '2026-09-08T00:00:00Z', enabled: true, - eventTypes: ['message.received'], + eventTypes: state.savedEventTypes, format: 'native', headers: [], id: String(args[0]), diff --git a/test/developer/render.test.ts b/test/developer/render.test.ts index f022b578..a5445800 100644 --- a/test/developer/render.test.ts +++ b/test/developer/render.test.ts @@ -60,12 +60,34 @@ describe('developer detail renderers', () => { 'Enabled\ttrue', 'Format\tnative', 'Event types\tmessage.received, session.connected', + 'Phone offline warning\toff', 'Header names\tx-team', 'Created\t2026-09-08T00:00:00Z', 'Updated\t2026-09-08T01:00:00Z', ]) }) + test('renders the phone offline start day for a subscribed webhook', () => { + const base = { + createdAt: '2026-09-08T00:00:00Z', + enabled: true, + eventTypes: ['session.phone_offline'], + format: 'native', + headers: [], + id: 'wh_1', + owner: 'project', + projectId: '11111111-1111-4111-8111-111111111111', + updatedAt: '2026-09-08T01:00:00Z', + url: 'https://hooks.example.test/polymorfa', + } + expect(renderWebhookDetail({...base, phoneOfflineStartDay: 8})).toContain( + 'Phone offline warning\tfrom day 8', + ) + expect(renderWebhookDetail(base)).toContain( + 'Phone offline warning\tnot reported', + ) + }) + test('renders delivery status and last outcome', () => { const text = renderDeliveryDetail({ attemptCount: 2,