Skip to content
Draft
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,30 @@ polymorfa message send list --session session_123 --to +15551234567 \
--row 'Morning/am9=9:00' --row 'Afternoon/pm2=14:00|Near the office'
```

The interactive-message candidate requires API PR #562 and the matching SDK
revision. Linked Devices supports ten total controls, at most three CTAs, and
mixed replies with URL/call controls. Copy/catalog controls cannot mix with
replies. Reply IDs are preserved. Official API restrictions remain separate.

Attach an image, video or document header with `--media-type` and exactly one
`--url` or `--file`; `--title` is exclusive with a media header. For example:

```sh
polymorfa message send buttons --session session_123 --to +15551234567 \
--body 'Choose an option' --button 'reply:offer-interested=Interested' \
--button 'url:Details=https://example.com/offer' \
--media-type image --url https://example.com/header.jpg
polymorfa message send carousel --session session_123 --to +15551234567 \
--body 'Offers' --cards cards.json
```

`cards.json` is a JSON array of 2–10 cards, each with `body`, `buttons`, optional
`footer`, and exactly one `image` or `video` object containing `url` or `base64`.
Card controls share a category; reply IDs are unique across all cards. Use media
URLs for payloads larger than the CLI's 10 MiB JSON-input cap. Each carousel
uses one send and the existing idempotency key. Publication remains gated on
an accepted API revision, matching staging and a published matching SDK.

- Media commands take exactly one of `--file` or `--url`. Local files are
limited by `--max-bytes` (100 MiB by default) and the MIME type is inferred
from the extension unless `--mime-type` is set. `document` sends the local
Expand Down
13 changes: 13 additions & 0 deletions contracts/interactive-messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"repository": "polymorfa/polymorfa",
"family": "messaging",
"sourceCommit": "ae30811ef6675a38a507482676b7213141ebebb6",
"sourcePath": "apps/api/docs/openapi.json",
"sourceSha256": "4375210d40d5bd21cf0605bd89d73eabc41a32f67613cd6a56027e8704005f1f",
"published": false,
"sdkRepository": "polymorfa/sdks",
"sdkSourceCommit": "e64e528df2e2fe249f5422e2e88044bd19321940",
"sdkPullRequest": "https://github.com/polymorfa/sdks/pull/345",
"releaseRequirement": "Publish an SDK carrying this accepted contract after matching staging or production acceptance, then pin its exact registry version. Current clean CI fails because the installed registry SDK lacks the candidate types.",
"scope": "Mixed reply/URL/call controls, image/video/document button headers and bounded 2-10 image/video-card carousels."
}
1 change: 1 addition & 0 deletions scripts/prepare-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ try {
validateSdk(sdk.provenance, sdk.version, channel)
validateFeatureContractPins(sdk.provenance, [
JSON.parse(readFileSync('contracts/abprops-capabilities.json')),
JSON.parse(readFileSync('contracts/interactive-messages.json')),
])
const files = execFileSync('git', [
'ls-files',
Expand Down
75 changes: 64 additions & 11 deletions src/commands/message/send/buttons.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import {Flags} from '@oclif/core'
import type {SendButtonsMessageRequest} from '@polymorfa/sdk'
import type {ButtonsMedia, SendButtonsMessageRequest} from '@polymorfa/sdk'

Check failure on line 2 in src/commands/message/send/buttons.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Module '"@polymorfa/sdk"' has no exported member 'ButtonsMedia'.

import {PolymorfaConfigurationError} from '@polymorfa/sdk'

import {BaseCommand} from '../../../base-command.js'
import {
sendIdempotencyOptions,
sendContext,
sendContextFlags,
} from '../../../input/message-send.js'
import {parseButton} from '../../../services/message-content.js'
import {
mediaSource,
validateLinkedButtons,
parseButton,
} from '../../../services/message-content.js'
import {sendMessage} from '../../../services/messages.js'
import {mediaSourceFlags} from '../../../messaging/media-send.js'

export default class MessageSendButtons extends BaseCommand {
static override description =
'Send an interactive message with reply, URL, call, or copy-code buttons.'
'Send an interactive message with reply, URL, call, copy-code, or catalog buttons.'

static override examples = [
'<%= config.bin %> message send buttons --session session_123 --to +15551234567 --body "Confirm your order?" --button reply:yes=Yes --button reply:no=No',
Expand All @@ -21,28 +28,74 @@

static override flags = {
...sendContextFlags,
...mediaSourceFlags,
'media-type': Flags.string({
description:
'Optional image, video or document header. Requires --file or --url.',
options: ['image', 'video', 'document'],
}),
body: Flags.string({description: 'Message body.', required: true}),
button: Flags.string({
description:
'Button as reply:<id>=<text>, url:<text>=<url>, call:<text>=<+E.164>, or copy:<text>=<code>. Repeat for several buttons.',
'Button as reply:<id>=<text>, url:<text>=<url>, call:<text>=<+E.164>, copy:<text>=<code>, or catalog:<text>=<+E.164>[|<product-id>]. Use up to ten total buttons and three CTAs. Replies can mix with URL/call; copy/catalog cannot mix with replies.',
multiple: true,
required: true,
}),
footer: Flags.string({description: 'Footer text.'}),
title: Flags.string({description: 'Header title.'}),
title: Flags.string({
description: 'Header title.',
exclusive: ['media-type'],
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(MessageSendButtons)
const buttons = flags.button.map(parseButton)
validateLinkedButtons(buttons, true)
const mediaType = flags['media-type'] as
'image' | 'video' | 'document' | undefined
if (!mediaType && (flags.file !== undefined || flags.url !== undefined))
throw new PolymorfaConfigurationError(
'A media source requires --media-type.',
'media-type',
)
const source = mediaType
? await mediaSource({
file: flags.file,
url: flags.url,
mimeType: flags['mime-type'],
maxBytes: flags['max-bytes'],
})
: undefined
const media =
mediaType && source
? ({
type: mediaType,
...(source.url === undefined
? {base64: source.base64!}
: {url: source.url}),
...(source.mimeType === undefined
? {}
: {mimeType: source.mimeType}),
...(mediaType === 'document' && source.filename !== undefined
? {filename: source.filename}
: {}),
} as ButtonsMedia)
: undefined
const common = {
body: flags.body,
buttons,
...(flags.footer === undefined ? {} : {footer: flags.footer}),
}
const request: SendButtonsMessageRequest = {
...sendContext(flags),
content: {
buttons: {
body: flags.body,
buttons: flags.button.map(parseButton),
...(flags.footer === undefined ? {} : {footer: flags.footer}),
...(flags.title === undefined ? {} : {title: flags.title}),
},
buttons: media
? {...common, media}

Check failure on line 94 in src/commands/message/send/buttons.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Type '{ media: any; footer?: string; body: string; buttons: MessageButton[]; } | { title?: string; footer?: string; body: string; buttons: MessageButton[]; }' is not assignable to type 'ButtonsMessageContent' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
: {
...common,
...(flags.title === undefined ? {} : {title: flags.title}),
},
},
}
const result = await sendMessage(
Expand Down
54 changes: 54 additions & 0 deletions src/commands/message/send/carousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {Flags} from '@oclif/core'
import type {SendCarouselMessageRequest} from '@polymorfa/sdk'

Check failure on line 2 in src/commands/message/send/carousel.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

'"@polymorfa/sdk"' has no exported member named 'SendCarouselMessageRequest'. Did you mean 'SendMessageRequest'?
import {BaseCommand} from '../../../base-command.js'
import {readJsonInput} from '../../../api/request.js'
import {
sendContext,
sendContextFlags,
sendIdempotencyOptions,
} from '../../../input/message-send.js'
import {parseCarouselCards} from '../../../services/message-content.js'
import {sendMessage} from '../../../services/messages.js'

export default class MessageSendCarousel extends BaseCommand {
static override description =
'Send one Linked Devices carousel with 2–10 image/video cards.'
static override examples = [
'<%= config.bin %> message send carousel --session session_123 --to +15551234567 --body "Offers" --cards cards.json',
]
static override flags = {
...sendContextFlags,
body: Flags.string({description: 'Carousel body.', required: true}),
footer: Flags.string({description: 'Footer text.'}),
cards: Flags.string({
description:
'Read the 2–10 card JSON array from a file or stdin (-), up to 10 MiB. Use media URLs for larger payloads.',
required: true,
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(MessageSendCarousel)
const cards = parseCarouselCards(await readJsonInput(flags.cards))
const request: SendCarouselMessageRequest = {
...sendContext(flags),
content: {
carousel: {
body: flags.body,
cards,
...(flags.footer === undefined ? {} : {footer: flags.footer}),
},
},
}
const result = await sendMessage(
await this.messagingClient(flags),
flags.session,
request,
sendIdempotencyOptions(flags['idempotency-key']),
)
this.writeValue(
result,
`Sent carousel message ${result.data.id} to ${flags.to}.`,
flags,
)
}
}
Loading
Loading