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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand Down
22 changes: 18 additions & 4 deletions src/commands/webhook/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -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.',
}),
Expand All @@ -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<Record<string, unknown>> = 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']),
Expand Down
45 changes: 35 additions & 10 deletions src/commands/webhook/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<void> {
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<Record<string, unknown>> =
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({
Expand Down
48 changes: 48 additions & 0 deletions src/developer/phone-offline.ts
Original file line number Diff line number Diff line change
@@ -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<PhoneOfflineStartDay>({
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<Record<string, unknown>>,
value: PhoneOfflineStartDay,
savedEventTypes?: readonly string[],
): Record<string, unknown> {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle wildcard subscriptions when applying off

When a webhook's eventTypes contains the valid trailing-wildcard pattern session.*, filtering only the exact session.phone_offline string leaves that subscription unchanged, so --phone-offline-start-day off succeeds while the webhook continues receiving the warning event. Either rewrite wildcard subscriptions so they exclude this event or reject off with an actionable error when exclusion cannot be represented.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. Delivery matches subscriptions by exact event type (matchesSubscription uses events.includes(event.type), apps/api/packages/api/src/services/webhook-delivery.ts:600), so a stored session.* entry matches no event and the webhook does not receive session.phone_offline through it. Filtering the exact name is the complete opt-out.

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}
}
13 changes: 13 additions & 0 deletions src/developer/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize wildcard subscriptions in webhook view

For a webhook subscribed through session.*, this exact-membership check displays Phone offline warning: off even though the wildcard includes session.phone_offline. The renderer should apply the same trailing-wildcard matching semantics as event subscription handling before deciding that the warning is disabled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed, for the same reason: delivery has no wildcard matching, so a webhook subscribed only through session.* does not receive session.phone_offline and 'off' is the accurate display.

return webhook.phoneOfflineStartDay === undefined
? 'not reported'
: `from day ${webhook.phoneOfflineStartDay}`
}

export function renderWebhookDetail(webhook: {
readonly id: string
readonly owner: string
Expand All @@ -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 {
Expand All @@ -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],
Expand Down
145 changes: 144 additions & 1 deletion test/commands/developer-webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
createSecretAvailable: true,
listPages: [] as Array<Record<string, unknown>>,
rotationSecretAvailable: true,
savedEventTypes: ['message.received'] as string[],
}))

vi.mock('@polymorfa/sdk', async (importOriginal) => {
Expand Down Expand Up @@ -51,6 +52,7 @@ describe('developer webhook commands', () => {
state.createSecretAvailable = true
state.listPages.splice(0)
state.rotationSecretAvailable = true
state.savedEventTypes = ['message.received']
})

afterEach(async () => {
Expand Down Expand Up @@ -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')
})
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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]),
Expand Down
Loading