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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
51 changes: 51 additions & 0 deletions src/commands/campaign/reply-flows/attach.ts
Original file line number Diff line number Diff line change
@@ -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 %> <campaignId> --project-id <projectId> --reply-flow <replyFlowId>',
'<%= config.bin %> <%= command.id %> <campaignId> --project-id <projectId> --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<void> {
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,
)
}
}
60 changes: 60 additions & 0 deletions src/commands/campaign/reply-flows/create.ts
Original file line number Diff line number Diff line change
@@ -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 <projectId> --name "Follow-up" --input flow.json',
'<%= config.bin %> <%= command.id %> --project-id <projectId> --name "Follow-up v2" --flow-key <flowKey> --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<void> {
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)
}
}
37 changes: 37 additions & 0 deletions src/commands/campaign/reply-flows/view.ts
Original file line number Diff line number Diff line change
@@ -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 %> <replyFlowId> --project-id <projectId> --json',
]

static override flags = {
'project-id': Flags.string({
description: 'Project that owns the reply flow.',
required: true,
}),
}

async run(): Promise<void> {
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)
}
}
115 changes: 115 additions & 0 deletions src/services/campaign-reply-flows.ts
Original file line number Diff line number Diff line change
@@ -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<CampaignResult<CampaignReplyFlow>> {
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<CampaignResult<CampaignReplyFlow>> {
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<CampaignResult<CampaignReplyFlowAttachment>> {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function result<T>(data: T, metadata: ResponseMetadata): CampaignResult<T> {
return {
data,
metadata: {
attempts: metadata.attempts,
status: metadata.status,
...(metadata.apiVersion === undefined
? {}
: {apiVersion: metadata.apiVersion}),
...(metadata.requestId === undefined
? {}
: {requestId: metadata.requestId}),
},
}
}
Loading