diff --git a/README.md b/README.md index 34499efb..f0d8ddb5 100644 --- a/README.md +++ b/README.md @@ -1789,6 +1789,28 @@ recorded`; changed details are refused. `--amount-minor` is in the currency's minor unit and needs `--currency`. Human output never prints the event ID. The report lists reported value per currency and never converts between them. +#### Campaign reply flows (beta) + +A reply flow is the set of steps a campaign runs after a recipient replies. The +team must be enrolled in the beta, otherwise these commands exit with the API's +`403`. They use a team API key and the project ID: + +```shell +polymorfa campaign reply-flows create --project-id project_123 \ + --name "Follow-up" --input flow.json +polymorfa campaign reply-flows view reply_flow_123 --project-id project_123 --json +polymorfa campaign reply-flows attach campaign_123 --project-id project_123 \ + --reply-flow reply_flow_123 +polymorfa campaign reply-flows attach campaign_123 --project-id project_123 --detach +``` + +`--input` is the definition JSON (`entryNodeId` and `nodes`) from a file or +stdin (`-`). A revision is immutable; add a new one with `--flow-key` and +`--revision`. Each key and revision can be created once, so `create` is sent +once and never retried; after a lost response, run `view` before creating +again. Attach works only before a campaign launches. Flow form steps work on +Official numbers only. + The source exposes no campaign update, delete, validate, archive, duplicate, event, history, or replay route on the Messaging surface. The CLI does not substitute similarly named Platform campaign methods for these gaps. To follow diff --git a/package-lock.json b/package-lock.json index 2da282d3..11f75a03 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.20261004024706", + "@polymorfa/sdk": "0.1.0-dev.20261004043942", "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.20261004024706", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004024706.tgz", - "integrity": "sha512-lCgzm4tzkAoy+cl8oyMo+qLrujaBgVwJccqyAKRRpPbGnUVluG3BA7C6+7/wVkckcmo2b1csc+eCU/tPBngx0Q==", + "version": "0.1.0-dev.20261004043942", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004043942.tgz", + "integrity": "sha512-RUuvjURXCqHfx5MfRneMxQdOSnc60ouu+QXqaAajFoC7ZvdZ6HoEYl9D2LjRU9coWUHnBoak5hxf2lQrm+fmtQ==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index cd3d35bd..102f5af5 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.20261004024706", + "@polymorfa/sdk": "0.1.0-dev.20261004043942", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/campaign/reply-flows/attach.ts b/src/commands/campaign/reply-flows/attach.ts new file mode 100644 index 00000000..c1f6e11a --- /dev/null +++ b/src/commands/campaign/reply-flows/attach.ts @@ -0,0 +1,51 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + renderCampaignReplyFlowAttachment, + setCampaignReplyFlow, +} from '../../../services/campaign-reply-flows.js' + +export default class CampaignReplyFlowsAttach extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign ID.', required: true}), + } + + static override description = + 'Attach a reply flow revision to a draft or scheduled campaign, or detach it with --detach (beta; requires team enrollment). A launched campaign keeps its reply flow.' + + static override examples = [ + '<%= config.bin %> <%= command.id %> --project-id --reply-flow ', + '<%= config.bin %> <%= command.id %> --project-id --detach', + ] + + static override flags = { + detach: Flags.boolean({ + description: 'Remove the campaign reply flow.', + exactlyOne: ['detach', 'reply-flow'], + }), + 'project-id': Flags.string({ + description: 'Project that owns the campaign.', + required: true, + }), + 'reply-flow': Flags.string({ + description: 'Reply flow revision ID to run after replies.', + exactlyOne: ['detach', 'reply-flow'], + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignReplyFlowsAttach) + const result = await setCampaignReplyFlow( + await this.platformClient(flags), + args.campaign, + flags.detach ? null : (flags['reply-flow'] ?? null), + flags['project-id'], + ) + this.writeValue( + result, + renderCampaignReplyFlowAttachment(result.data), + flags, + ) + } +} diff --git a/src/commands/campaign/reply-flows/create.ts b/src/commands/campaign/reply-flows/create.ts new file mode 100644 index 00000000..b6b40181 --- /dev/null +++ b/src/commands/campaign/reply-flows/create.ts @@ -0,0 +1,60 @@ +import {Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + createCampaignReplyFlow, + renderCampaignReplyFlow, + replyFlowDefinition, +} from '../../../services/campaign-reply-flows.js' + +export default class CampaignReplyFlowsCreate extends BaseCommand { + static override description = `Create a reply flow revision: the steps a campaign runs after a recipient replies (beta; requires team enrollment). + +The input is the definition JSON ({entryNodeId, nodes}). Each flow key and revision can be created once; the CLI sends the request once and never retries it. Flow form steps work on Official numbers only.` + + static override examples = [ + '<%= config.bin %> <%= command.id %> --project-id --name "Follow-up" --input flow.json', + '<%= config.bin %> <%= command.id %> --project-id --name "Follow-up v2" --flow-key --revision 2 --input flow.json', + ] + + static override flags = { + 'flow-key': Flags.string({ + description: + 'Existing flow key to add a revision to. Omit to start a new reply flow.', + }), + input: Flags.string({ + description: + 'Read the reply flow definition JSON from a file or stdin (-).', + required: true, + }), + name: Flags.string({description: 'Reply flow name.', required: true}), + 'project-id': Flags.string({ + description: 'Project that owns the reply flow.', + required: true, + }), + revision: Flags.integer({ + description: 'Revision number. Defaults to 1.', + max: 1_000_000, + min: 1, + }), + } + + async run(): Promise { + const {flags} = await this.parse(CampaignReplyFlowsCreate) + const definition = replyFlowDefinition(await readJsonInput(flags.input)) + const result = await createCampaignReplyFlow( + await this.platformClient(flags), + { + definition, + name: flags.name, + projectId: flags['project-id'], + ...(flags['flow-key'] === undefined + ? {} + : {flowKey: flags['flow-key']}), + ...(flags.revision === undefined ? {} : {revision: flags.revision}), + }, + ) + this.writeValue(result, renderCampaignReplyFlow(result.data), flags) + } +} diff --git a/src/commands/campaign/reply-flows/view.ts b/src/commands/campaign/reply-flows/view.ts new file mode 100644 index 00000000..0ded1acd --- /dev/null +++ b/src/commands/campaign/reply-flows/view.ts @@ -0,0 +1,37 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + renderCampaignReplyFlow, + viewCampaignReplyFlow, +} from '../../../services/campaign-reply-flows.js' + +export default class CampaignReplyFlowsView extends BaseCommand { + static override args = { + replyFlow: Args.string({description: 'Reply flow ID.', required: true}), + } + + static override description = + 'View one reply flow revision (beta; requires team enrollment). Use --json for the full definition.' + + static override examples = [ + '<%= config.bin %> <%= command.id %> --project-id --json', + ] + + static override flags = { + 'project-id': Flags.string({ + description: 'Project that owns the reply flow.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignReplyFlowsView) + const result = await viewCampaignReplyFlow( + await this.platformClient(flags), + args.replyFlow, + flags['project-id'], + ) + this.writeValue(result, renderCampaignReplyFlow(result.data), flags) + } +} diff --git a/src/services/campaign-reply-flows.ts b/src/services/campaign-reply-flows.ts new file mode 100644 index 00000000..bc0b2a05 --- /dev/null +++ b/src/services/campaign-reply-flows.ts @@ -0,0 +1,115 @@ +import type { + CampaignReplyFlow, + CampaignReplyFlowAttachment, + CampaignReplyFlowDefinition, + ClientInstance, + ResponseMetadata, +} from '@polymorfa/sdk' + +import type {CampaignResult} from './campaigns.js' + +export interface CreateReplyFlowInput { + definition: CampaignReplyFlowDefinition + flowKey?: string + name: string + projectId: string + revision?: number +} + +/** Create one immutable reply flow revision (beta). Sent once, never retried. */ +export async function createCampaignReplyFlow( + client: ClientInstance<'organization'>, + input: CreateReplyFlowInput, +): Promise> { + const response = await client.campaigns.createReplyFlow({ + definition: input.definition, + name: input.name, + projectId: input.projectId, + ...(input.flowKey === undefined ? {} : {flowKey: input.flowKey}), + ...(input.revision === undefined ? {} : {revision: input.revision}), + }) + return result(response.data.data, response.metadata) +} + +/** Read one reply flow revision (beta). */ +export async function viewCampaignReplyFlow( + client: ClientInstance<'organization'>, + replyFlowId: string, + projectId: string, +): Promise> { + const response = await client.campaigns.retrieveReplyFlow(replyFlowId, { + projectId, + }) + return result(response.data.data, response.metadata) +} + +/** Attach a reply flow revision to a campaign, or detach with null (beta). */ +export async function setCampaignReplyFlow( + client: ClientInstance<'organization'>, + campaignId: string, + replyFlowId: null | string, + projectId: string, +): Promise> { + const response = await client.campaigns.setReplyFlow(campaignId, { + projectId, + replyFlowId, + }) + return result(response.data.data, response.metadata) +} + +/** Accept the definition alone or a `{definition}` wrapper. */ +export function replyFlowDefinition( + value: unknown, +): CampaignReplyFlowDefinition { + const candidate = + isObject(value) && isObject(value.definition) ? value.definition : value + if ( + !isObject(candidate) || + typeof candidate.entryNodeId !== 'string' || + !Array.isArray(candidate.nodes) + ) { + throw new Error( + 'The input must be a reply flow definition with entryNodeId and nodes.', + ) + } + return candidate as unknown as CampaignReplyFlowDefinition +} + +export function renderCampaignReplyFlow(flow: CampaignReplyFlow): string { + return [ + `Reply flow ${flow.id}`, + `Name\t${flow.name}`, + `Flow key\t${flow.flowKey}`, + `Revision\t${flow.revision}`, + `Steps\t${flow.definition.nodes.length}`, + `Entry step\t${flow.definition.entryNodeId}`, + ].join('\n') +} + +export function renderCampaignReplyFlowAttachment( + attachment: CampaignReplyFlowAttachment, +): string { + return attachment.replyFlowId === null + ? `Campaign ${attachment.campaignId} has no reply flow.` + : `Campaign ${attachment.campaignId} runs reply flow ${attachment.replyFlowId}.` +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function result(data: T, metadata: ResponseMetadata): CampaignResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} diff --git a/test/commands/campaign-reply-flows.test.ts b/test/commands/campaign-reply-flows.test.ts new file mode 100644 index 00000000..475c64ea --- /dev/null +++ b/test/commands/campaign-reply-flows.test.ts @@ -0,0 +1,163 @@ +import {runCommand} from '@oclif/test' +import {mkdtemp, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const PROJECT = '0199a000-0000-7000-8000-000000000002' +const CAMPAIGN = '0199a000-0000-7000-8000-000000000003' +const FLOW = '0199a000-0000-7000-8000-000000000005' +const KEY = '0199a000-0000-7000-8000-000000000006' + +const definition = { + entryNodeId: 'start', + nodes: [ + { + edges: [ + {match: {id: 'yes', kind: 'choice', replyKind: 'button'}, to: 'thanks'}, + ], + id: 'start', + }, + {edges: [], id: 'thanks', send: {kind: 'text', text: 'Thanks!'}}, + ], +} + +const replyFlow = { + createdAt: 1_790_000_000_000, + definition, + flowKey: KEY, + id: FLOW, + name: 'Follow-up', + revision: 2, +} + +describe('Campaign reply flow commands (beta)', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('creates a revision from a definition file', async () => { + server = await startHttpServer({body: {data: replyFlow}}) + const directory = await mkdtemp(join(tmpdir(), 'reply-flow-')) + const input = join(directory, 'flow.json') + await writeFile(input, JSON.stringify({definition})) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign reply-flows create --project-id ${PROJECT} --name Follow-up --flow-key ${KEY} --revision 2 --input ${input}`, + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.method).toBe('POST') + expect(server.requests[0]?.url).toBe('/platform/campaign-reply-flows') + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + definition, + flowKey: KEY, + name: 'Follow-up', + projectId: PROJECT, + revision: 2, + }) + expect(result.stdout).toContain(`Reply flow ${FLOW}`) + expect(result.stdout).toContain('Revision\t2') + }) + + test('rejects input that is not a definition before any request', async () => { + server = await startHttpServer({body: {data: replyFlow}}) + const directory = await mkdtemp(join(tmpdir(), 'reply-flow-')) + const input = join(directory, 'flow.json') + await writeFile(input, JSON.stringify({nodes: []})) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign reply-flows create --project-id ${PROJECT} --name x --input ${input}`, + ), + ) + + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + + test('views a revision for the project', async () => { + server = await startHttpServer({body: {data: replyFlow}}) + + const result = await apiKeyEnvironment(() => + runCommand(`campaign reply-flows view ${FLOW} --project-id ${PROJECT}`), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.method).toBe('GET') + expect(server.requests[0]?.url).toBe( + `/platform/campaign-reply-flows/${FLOW}?projectId=${PROJECT}`, + ) + expect(result.stdout).toContain('Steps\t2') + }) + + test('attaches and detaches a reply flow', async () => { + server = await startHttpServer({ + body: {data: {campaignId: CAMPAIGN, replyFlowId: FLOW}}, + }) + + const attached = await apiKeyEnvironment(() => + runCommand( + `campaign reply-flows attach ${CAMPAIGN} --project-id ${PROJECT} --reply-flow ${FLOW}`, + ), + ) + expect(attached.error).toBeUndefined() + expect(server.requests[0]?.method).toBe('PUT') + expect(server.requests[0]?.url).toBe( + `/platform/campaigns/${CAMPAIGN}/reply-flow`, + ) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + projectId: PROJECT, + replyFlowId: FLOW, + }) + expect(attached.stdout).toContain(`runs reply flow ${FLOW}`) + + await apiKeyEnvironment(() => + runCommand( + `campaign reply-flows attach ${CAMPAIGN} --project-id ${PROJECT} --detach`, + ), + ) + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + projectId: PROJECT, + replyFlowId: null, + }) + }) + + test('requires exactly one of --reply-flow and --detach', async () => { + server = await startHttpServer({body: {data: {}}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign reply-flows attach ${CAMPAIGN} --project-id ${PROJECT}`, + ), + ) + + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + + async function apiKeyEnvironment(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +})