diff --git a/README.md b/README.md index d4cd6e0..e480fb9 100644 --- a/README.md +++ b/README.md @@ -1781,6 +1781,11 @@ polymorfa campaign list --project-slug launch-project polymorfa campaign create --project-slug launch-project --input campaign.json polymorfa campaign view campaign_123 --project-slug launch-project polymorfa campaign analytics campaign_123 --project-slug launch-project +polymorfa campaign test-send campaign_123 \ + --project-slug launch-project \ + --input test-send.json \ + --idempotency-key campaign-test-001 \ + --yes polymorfa campaign launch campaign_123 \ --project-slug launch-project \ @@ -1800,6 +1805,16 @@ Check the campaign with `polymorfa campaign view`. When an operation ID is returned, follow it with `polymorfa operation wait` or `polymorfa operation watch`; see [Operations](#operations). +`campaign test-send` sends one real message from a draft to a team-connected +seed number. Its JSON input requires `sessionId` (the sending number's session +ID) and `to` (the seed phone in E.164 format); `sampleRecipientId` and +`variables` (an object of string, number or boolean values) are optional. The +CLI rejects input that does not match before contacting the API. The command requires `--yes` and an explicit +`--idempotency-key`. A timeout or `result_unknown` does not prove the message +failed. Check the seed phone and reuse the same key to reconcile the attempt; +do not send with a new key until you know the outcome. Test sends are limited +to 20 per team and 5 per seed number in a rolling hour. + Stop is terminal and requires `--yes`. A campaign that has an active delivery run answers with an operation ID, as the other lifecycle commands do. A campaign with no active run is cancelled immediately and answers without one; diff --git a/contracts/cli-reference.json b/contracts/cli-reference.json index 432e7ab..c354320 100644 --- a/contracts/cli-reference.json +++ b/contracts/cli-reference.json @@ -19747,6 +19747,182 @@ "examples": [], "state": null }, + { + "id": "campaign test-send", + "summary": "Send one draft campaign test to a team-connected seed number.", + "description": "Send one draft campaign test to a team-connected seed number.", + "usage": ["polymorfa campaign test-send CAMPAIGN [flags]"], + "aliases": [], + "hidden": false, + "args": [ + { + "name": "campaign", + "description": "Draft campaign ID.", + "required": true, + "options": [] + } + ], + "flags": [ + { + "name": "api-version", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Override the API version for this request.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "idempotency-key", + "char": null, + "type": "option", + "required": true, + "multiple": false, + "default": null, + "options": [], + "description": "Required deduplication key. Reuse this key after an uncertain result; never retry with a new key before checking the seed phone.", + "env": null, + "helpGroup": null + }, + { + "name": "input", + "char": null, + "type": "option", + "required": true, + "multiple": false, + "default": null, + "options": [], + "description": "Read one JSON object with sessionId, to, and optional sampleRecipientId or variables from a file or stdin (-).", + "env": null, + "helpGroup": null + }, + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON document.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "no-color", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Disable color output.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "owner", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": ["project", "organization"], + "description": "Select project-owned or organization-owned resources.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Use a named authentication profile.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "project", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Use a project other than the active context.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "project-slug", + "char": null, + "type": "option", + "required": true, + "multiple": false, + "default": null, + "options": [], + "description": "Project slug in the Messaging API path.", + "env": null, + "helpGroup": null + }, + { + "name": "quiet", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Suppress successful output.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Fail the request after this many milliseconds.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "verbose", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Explain context and workflow decisions.", + "env": null, + "helpGroup": "GLOBAL" + }, + { + "name": "yes", + "char": null, + "type": "boolean", + "required": true, + "multiple": false, + "default": null, + "options": [], + "description": "Confirm sending one real WhatsApp message to the seed number.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, { "id": "campaign update", "summary": "Update a campaign. Omitted drip fields preserve stored values; enrollment-only audience closes enrollment. Cap flags replace the campaign cap; omitted cap windows have no campaign limit.", diff --git a/src/browser/registry.gen.ts b/src/browser/registry.gen.ts index d2d1587..8f055dd 100644 --- a/src/browser/registry.gen.ts +++ b/src/browser/registry.gen.ts @@ -184,6 +184,7 @@ export const COMMAND_LOADERS: Readonly< 'campaign settings frequency-cap set': () => import('../commands/campaign/settings/frequency-cap/set.js'), 'campaign stop': () => import('../commands/campaign/stop.js'), + 'campaign test-send': () => import('../commands/campaign/test-send.js'), 'campaign update': () => import('../commands/campaign/update.js'), 'campaign view': () => import('../commands/campaign/view.js'), 'channel create': () => import('../commands/channel/create.js'), diff --git a/src/commands/campaign/test-send.ts b/src/commands/campaign/test-send.ts new file mode 100644 index 0000000..a7256a9 --- /dev/null +++ b/src/commands/campaign/test-send.ts @@ -0,0 +1,51 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import { + renderCampaignTestSend, + testSendCampaign, +} from '../../services/campaigns.js' + +export default class CampaignTestSend extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Draft campaign ID.', required: true}), + } + + static override description = + 'Send one draft campaign test to a team-connected seed number.' + + static override flags = { + 'idempotency-key': Flags.string({ + description: + 'Required deduplication key. Reuse this key after an uncertain result; never retry with a new key before checking the seed phone.', + required: true, + }), + input: Flags.string({ + description: + 'Read one JSON object with sessionId, to, and optional sampleRecipientId or variables from a file or stdin (-).', + required: true, + }), + 'project-slug': Flags.string({ + description: 'Project slug in the Messaging API path.', + required: true, + }), + yes: Flags.boolean({ + description: + 'Confirm sending one real WhatsApp message to the seed number.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignTestSend) + const result = await testSendCampaign( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + await readJsonInput(flags.input), + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderCampaignTestSend(result.data), flags) + } +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 58ec7ba..dca639b 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -22,6 +22,8 @@ import { type CampaignRecipientStatus, type CampaignRequeueResult, type CampaignStopOperation, + type CampaignTestSend, + type CampaignTestSendRequest, type CreateCampaignRequest, type InvalidRecipientRow, type ListCampaignRecipientsParams, @@ -97,6 +99,22 @@ export async function launchCampaign( return result(response.data.data, response.metadata) } +export async function testSendCampaign( + client: MessagingClient, + projectSlug: string, + campaignId: string, + input: unknown, + options: RequestOptions, +): Promise> { + const response = await client.campaigns.testSend( + projectSlug, + campaignId, + campaignTestSendInput(input), + options, + ) + return result(response.data.data, response.metadata) +} + export async function pauseCampaign( client: MessagingClient, projectSlug: string, @@ -284,6 +302,10 @@ export function renderCampaignRequeue(result: CampaignRequeueResult): string { return `Requeued ${result.requeued} ${result.requeued === 1 ? 'recipient' : 'recipients'}.` } +export function renderCampaignTestSend(testSend: CampaignTestSend): string { + return `Sent one campaign test to ${testSend.to} from ${testSend.from.phone}. Test ID: ${testSend.id}.` +} + /** * Stop answers with a null operation ID when the campaign had no delivery run * to stop. Saying "accepted as operation null" would be untrue, so the two @@ -384,6 +406,58 @@ function campaignInput(input: unknown): CreateCampaignRequest { return input as unknown as CreateCampaignRequest } +const E164_PHONE = /^\+[1-9]\d{1,14}$/ + +function isTestVariableValue(value: unknown): boolean { + return ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) +} + +function campaignTestSendInput(input: unknown): CampaignTestSendRequest { + if ( + !isRecord(input) || + typeof input.sessionId !== 'string' || + input.sessionId.trim().length === 0 || + typeof input.to !== 'string' || + input.to.trim().length === 0 + ) { + throw new PolymorfaValidationError( + 'Campaign test input must contain one JSON object with sessionId and to.', + {code: 'invalid_campaign_test_input'}, + ) + } + if (!E164_PHONE.test(input.to)) { + throw new PolymorfaValidationError( + 'Campaign test input to must be an E.164 phone number, such as +15550000002.', + {code: 'invalid_campaign_test_input'}, + ) + } + if ( + input.sampleRecipientId !== undefined && + (typeof input.sampleRecipientId !== 'string' || + input.sampleRecipientId.trim().length === 0) + ) { + throw new PolymorfaValidationError( + 'Campaign test input sampleRecipientId must be a non-empty string.', + {code: 'invalid_campaign_test_input'}, + ) + } + if ( + input.variables !== undefined && + (!isRecord(input.variables) || + !Object.values(input.variables).every(isTestVariableValue)) + ) { + throw new PolymorfaValidationError( + 'Campaign test input variables must be an object of string, number or boolean values.', + {code: 'invalid_campaign_test_input'}, + ) + } + return input as unknown as CampaignTestSendRequest +} + function result(data: T, metadata: ResponseMetadata): CampaignResult { return { data, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index c42c87f..e01a2c2 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -176,6 +176,102 @@ describe('Messaging campaign commands', () => { expect(server.requests).toHaveLength(0) }) + test('sends one campaign test with a reusable key and renders its receipt', async () => { + const input = await jsonInput('test-send.json', { + sessionId: '00000000-0000-4000-8000-000000000001', + to: '+15550000002', + variables: {firstName: 'Ada'}, + }) + const receipt = { + campaignId: 'campaign_1', + createdAt: 1_780_000_000_000, + from: { + phone: '+15550000001', + sessionId: '00000000-0000-4000-8000-000000000001', + }, + id: 'test_1', + messageId: 'wamid.1', + status: 'sent', + to: '+15550000002', + variables: {firstName: 'Ada'}, + } + server = await startHttpServer({body: {data: receipt, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign test-send campaign_1 --project-slug project_1 --input ${input} --idempotency-key test_1 --yes`, + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + 'Sent one campaign test to +15550000002 from +15550000001. Test ID: test_1.', + ) + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({ + sessionId: '00000000-0000-4000-8000-000000000001', + to: '+15550000002', + variables: {firstName: 'Ada'}, + }), + method: 'POST', + url: '/messaging/projects/project_1/campaigns/campaign_1/test-send', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('test_1') + }) + + test.each([ + ['a non-E.164 seed phone', {to: 'not-a-phone'}], + ['a seed phone without a plus sign', {to: '15550000002'}], + ['non-object variables', {to: '+15550000002', variables: 42}], + ['an array of variables', {to: '+15550000002', variables: ['Ada']}], + [ + 'a nested variable value', + {to: '+15550000002', variables: {name: {first: 'Ada'}}}, + ], + [ + 'a non-string sample recipient', + {to: '+15550000002', sampleRecipientId: 7}, + ], + ])( + 'rejects campaign test input with %s before sending', + async (_case, fields) => { + const input = await jsonInput('test-send.json', { + sessionId: '00000000-0000-4000-8000-000000000001', + ...fields, + }) + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign test-send campaign_1 --project-slug project_1 --input ${input} --idempotency-key test_1 --yes --json`, + ), + ) + + expect(result.error).toBeDefined() + expect( + result.stdout + result.stderr + String(result.error?.message), + ).toMatch(/invalid_campaign_test_input|Campaign test input/) + expect(server.requests).toHaveLength(0) + }, + ) + + test('requires explicit send confirmation before contacting the API', async () => { + const input = await jsonInput('test-send.json', { + sessionId: '00000000-0000-4000-8000-000000000001', + to: '+15550000002', + }) + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign test-send campaign_1 --project-slug project_1 --input ${input} --idempotency-key test_1`, + ), + ) + + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + test('pauses, resumes, and explicitly stops through distinct lifecycle routes', async () => { server = await startHttpSequenceServer([ {body: {data: {...campaign(), operationId: 'op_pause'}, success: true}},