From ecf7e619f6e6b21ce747ab24448c1abb5f58519f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 12:01:12 +0300 Subject: [PATCH 1/4] feat: send catalog buttons and enforce valid interactive combinations --- src/commands/message/send/buttons.ts | 20 ++++++- src/services/message-content.ts | 20 ++++++- test/commands/message-sends.test.ts | 82 ++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src/commands/message/send/buttons.ts b/src/commands/message/send/buttons.ts index 19bfef03..20a1589d 100644 --- a/src/commands/message/send/buttons.ts +++ b/src/commands/message/send/buttons.ts @@ -1,6 +1,8 @@ import {Flags} from '@oclif/core' import type {SendButtonsMessageRequest} from '@polymorfa/sdk' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + import {BaseCommand} from '../../../base-command.js' import { sendIdempotencyOptions, @@ -12,7 +14,7 @@ import {sendMessage} from '../../../services/messages.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', @@ -24,7 +26,7 @@ export default class MessageSendButtons extends BaseCommand { body: Flags.string({description: 'Message body.', required: true}), button: Flags.string({ description: - 'Button as reply:=, url:=, call:=<+E.164>, or copy:=. Repeat for several buttons.', + 'Button as reply:=, url:=, call:=<+E.164>, copy:=, or catalog:=<+E.164>[|]. Use up to ten replies or three CTAs; do not mix them.', multiple: true, required: true, }), @@ -34,12 +36,24 @@ export default class MessageSendButtons extends BaseCommand { async run(): Promise { const {flags} = await this.parse(MessageSendButtons) + const buttons = flags.button.map(parseButton) + const replies = buttons.filter((button) => button.type === 'reply').length + if ( + buttons.length > 10 || + (replies > 0 && replies !== buttons.length) || + (replies === 0 && buttons.length > 3) + ) { + throw new PolymorfaConfigurationError( + 'Use up to ten reply buttons or three CTA buttons, without mixing them.', + 'button', + ) + } const request: SendButtonsMessageRequest = { ...sendContext(flags), content: { buttons: { body: flags.body, - buttons: flags.button.map(parseButton), + buttons, ...(flags.footer === undefined ? {} : {footer: flags.footer}), ...(flags.title === undefined ? {} : {title: flags.title}), }, diff --git a/src/services/message-content.ts b/src/services/message-content.ts index 4e26ffa1..26eace22 100644 --- a/src/services/message-content.ts +++ b/src/services/message-content.ts @@ -144,7 +144,7 @@ export async function mediaSource( /** * Parse `--button` values: - * reply:=, url:=, call:=, copy:= + * reply:=, url:=, call:=, copy:=, catalog:=<+E.164>[|] */ export function parseButton(value: string): MessageButton { const colon = value.indexOf(':') @@ -164,6 +164,22 @@ export function parseButton(value: string): MessageButton { case 'call': if (!/^\+[1-9]\d{1,14}$/.test(right)) throw invalidButton(value) return {type: 'call', text: left, phoneNumber: right} + case 'catalog': { + const [businessPhoneNumber, catalogProductId, extra] = right.split('|') + if ( + businessPhoneNumber === undefined || + !/^\+[1-9]\d{1,14}$/.test(businessPhoneNumber) || + extra !== undefined || + (catalogProductId !== undefined && !/^[1-9]\d*$/.test(catalogProductId)) + ) + throw invalidButton(value) + return { + type: 'catalog', + text: left, + businessPhoneNumber, + ...(catalogProductId === undefined ? {} : {catalogProductId}), + } + } case 'copy': return {type: 'copy', text: left, copyCode: right} default: @@ -173,7 +189,7 @@ export function parseButton(value: string): MessageButton { function invalidButton(value: string): PolymorfaConfigurationError { return new PolymorfaConfigurationError( - `Invalid --button "${value}". Use reply:=, url:=, call:=<+E.164>, or copy:=.`, + `Invalid --button "${value}". Use reply:=, url:=, call:=<+E.164>, copy:=, or catalog:=<+E.164>[|].`, 'button', ) } diff --git a/test/commands/message-sends.test.ts b/test/commands/message-sends.test.ts index e474ea9b..56dcd5f4 100644 --- a/test/commands/message-sends.test.ts +++ b/test/commands/message-sends.test.ts @@ -637,7 +637,7 @@ describe('typed message send commands', () => { expect(server.requests).toHaveLength(0) }) - test('sends buttons of every supported type', async () => { + test('sends three CTA buttons', async () => { server = await messageServer() const result = await send([ @@ -653,8 +653,6 @@ describe('typed message send commands', () => { '--footer', 'Thanks', '--button', - 'reply:yes=Yes', - '--button', 'url:Open=https://example.com/a?b=c', '--button', '"call:Call us=+15550001111"', @@ -669,7 +667,6 @@ describe('typed message send commands', () => { buttons: { body: 'Pick', buttons: [ - {id: 'yes', text: 'Yes', type: 'reply'}, {text: 'Open', type: 'url', url: 'https://example.com/a?b=c'}, {phoneNumber: '+15550001111', text: 'Call us', type: 'call'}, {copyCode: 'SAVE10', text: 'Code', type: 'copy'}, @@ -680,6 +677,83 @@ describe('typed message send commands', () => { }) }) + test('preserves ten replies and a catalog product button', async () => { + server = await messageServer() + const common = [ + 'message', + 'send', + 'buttons', + '--session', + 'session_1', + '--to', + '730000000001', + '--body', + 'Pick', + '--json', + ] + const result = await send([ + ...common, + ...Array.from({length: 10}, (_, i) => [ + '--button', + `reply:choice-${i}=Choice-${i}`, + ]).flat(), + ]) + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + buttons: { + buttons: Array.from({length: 10}, (_, i) => ({ + type: 'reply', + id: `choice-${i}`, + text: `Choice-${i}`, + })), + }, + }, + }) + const catalog = await send([ + ...common, + '--button', + 'catalog:Browse=+15550001111|123', + ]) + expect(catalog.error).toBeUndefined() + expect(JSON.parse(server.requests.at(-1)!.body)).toMatchObject({ + content: { + buttons: { + buttons: [ + { + type: 'catalog', + text: 'Browse', + businessPhoneNumber: '+15550001111', + catalogProductId: '123', + }, + ], + }, + }, + }) + }) + + test('rejects reply and CTA mixtures before transport', async () => { + server = await messageServer() + const result = await send([ + 'message', + 'send', + 'buttons', + '--session', + 'session_1', + '--to', + '730000000001', + '--body', + 'Pick', + '--button', + 'reply:yes=Yes', + '--button', + 'copy:Copy=CODE', + '--json', + ]) + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + test('rejects malformed buttons before transport', async () => { server = await messageServer() From 9267e582fc9cf4aec0a32fae49e646a7a2ffa84c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 14 Oct 2026 23:01:19 +0300 Subject: [PATCH 2/4] feat: send mixed native buttons, media headers and carousels --- README.md | 24 ++++ src/commands/message/send/buttons.ts | 75 ++++++++--- src/commands/message/send/carousel.ts | 54 ++++++++ src/services/message-content.ts | 185 ++++++++++++++++++++++++++ test/commands/message-sends.test.ts | 108 ++++++++++++++- 5 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 src/commands/message/send/carousel.ts diff --git a/README.md b/README.md index efbdc3c2..9f6ecd64 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/commands/message/send/buttons.ts b/src/commands/message/send/buttons.ts index 20a1589d..18517905 100644 --- a/src/commands/message/send/buttons.ts +++ b/src/commands/message/send/buttons.ts @@ -1,5 +1,5 @@ import {Flags} from '@oclif/core' -import type {SendButtonsMessageRequest} from '@polymorfa/sdk' +import type {ButtonsMedia, SendButtonsMessageRequest} from '@polymorfa/sdk' import {PolymorfaConfigurationError} from '@polymorfa/sdk' @@ -9,8 +9,13 @@ import { 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 = @@ -23,40 +28,74 @@ export default class MessageSendButtons extends BaseCommand { 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:=, url:=, call:=<+E.164>, copy:=, or catalog:=<+E.164>[|]. Use up to ten replies or three CTAs; do not mix them.', + 'Button as reply:=, url:=, call:=<+E.164>, copy:=, or catalog:=<+E.164>[|]. 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 { const {flags} = await this.parse(MessageSendButtons) const buttons = flags.button.map(parseButton) - const replies = buttons.filter((button) => button.type === 'reply').length - if ( - buttons.length > 10 || - (replies > 0 && replies !== buttons.length) || - (replies === 0 && buttons.length > 3) - ) { + validateLinkedButtons(buttons, true) + const mediaType = flags['media-type'] as + 'image' | 'video' | 'document' | undefined + if (!mediaType && (flags.file !== undefined || flags.url !== undefined)) throw new PolymorfaConfigurationError( - 'Use up to ten reply buttons or three CTA buttons, without mixing them.', - 'button', + '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.footer === undefined ? {} : {footer: flags.footer}), - ...(flags.title === undefined ? {} : {title: flags.title}), - }, + buttons: media + ? {...common, media} + : { + ...common, + ...(flags.title === undefined ? {} : {title: flags.title}), + }, }, } const result = await sendMessage( diff --git a/src/commands/message/send/carousel.ts b/src/commands/message/send/carousel.ts new file mode 100644 index 00000000..2cf2a97f --- /dev/null +++ b/src/commands/message/send/carousel.ts @@ -0,0 +1,54 @@ +import {Flags} from '@oclif/core' +import type {SendCarouselMessageRequest} from '@polymorfa/sdk' +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 { + 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, + ) + } +} diff --git a/src/services/message-content.ts b/src/services/message-content.ts index 26eace22..d058c9d1 100644 --- a/src/services/message-content.ts +++ b/src/services/message-content.ts @@ -1,5 +1,7 @@ import {readFile, stat} from 'node:fs/promises' import {basename, extname} from 'node:path' +import {z} from 'zod' +import type {InteractiveMediaSource, CarouselCard} from '@polymorfa/sdk' import { PolymorfaConfigurationError, @@ -296,3 +298,186 @@ function escapeVcard(value: string): string { .replace(/,/g, '\\,') .replace(/;/g, '\\;') } + +/** Linked format limits, checked before credential resolution or HTTP work. */ +export function validateLinkedButtons( + buttons: readonly MessageButton[], + allowMixed: boolean, +): void { + const replies = buttons.filter((button) => button.type === 'reply') + const ctas = buttons.length - replies.length + if ( + buttons.length < 1 || + buttons.length > 10 || + ctas > 3 || + (replies.length > 0 && + ctas > 0 && + (!allowMixed || + buttons.some( + (button) => button.type === 'copy' || button.type === 'catalog', + ))) + ) + throw new PolymorfaConfigurationError( + 'Use up to ten total buttons and three CTAs. Replies can mix with URL/call; carousel cards use replies or CTAs.', + 'button', + ) + const ids = new Set() + for (const button of buttons) { + if (!button.text.trim() || Buffer.byteLength(button.text, 'utf8') > 256) + throw new PolymorfaConfigurationError( + 'Button labels must contain 1–256 UTF-8 bytes.', + 'button', + ) + if (button.type === 'reply') { + if ( + !button.id.trim() || + Buffer.byteLength(button.id, 'utf8') > 256 || + /\p{Cc}/u.test(button.id) || + ids.has(button.id) + ) + throw new PolymorfaConfigurationError( + 'Reply IDs must be unique nonempty strings of at most 256 UTF-8 bytes.', + 'button', + ) + ids.add(button.id) + } + } +} + +const jsonButton = z.discriminatedUnion('type', [ + z + .object({type: z.literal('reply'), text: z.string(), id: z.string()}) + .strict(), + z + .object({ + type: z.literal('url'), + text: z.string(), + url: z.string().url().max(2048).startsWith('https://'), + }) + .strict(), + z + .object({ + type: z.literal('call'), + text: z.string(), + phoneNumber: z.string().regex(/^\+?\d{6,20}$/), + }) + .strict(), + z + .object({ + type: z.literal('copy'), + text: z.string(), + copyCode: z.string().min(1).max(256), + }) + .strict(), + z + .object({ + type: z.literal('catalog'), + text: z.string(), + businessPhoneNumber: z.string().regex(/^\+?\d{6,20}$/), + catalogProductId: z.string().max(256).optional(), + }) + .strict(), +]) +const mediaJson = z.object({ + url: z.string().url().optional(), + base64: z + .string() + .min(1) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) + .optional(), + mimeType: z.string().max(256).optional(), +}) +const oneSource = (value: { + url?: string | undefined + base64?: string | undefined +}) => (value.url === undefined) !== (value.base64 === undefined) +const cardJson = z.object({ + body: z + .string() + .min(1) + .refine((s) => Buffer.byteLength(s, 'utf8') <= 4096), + footer: z + .string() + .refine((s) => Buffer.byteLength(s, 'utf8') <= 256) + .optional(), + buttons: z.array(jsonButton).min(1).max(10), +}) +const carouselCardsJson = z + .array( + z.union([ + cardJson.extend({image: mediaJson.strict().refine(oneSource)}).strict(), + cardJson + .extend({ + video: mediaJson + .extend({thumbnail: z.string().max(21848).optional()}) + .strict() + .refine(oneSource), + }) + .strict(), + ]), + ) + .min(2) + .max(10) + +export function parseCarouselCards(value: unknown): readonly CarouselCard[] { + const parsed = carouselCardsJson.safeParse(value) + if (!parsed.success) + throw new PolymorfaConfigurationError( + 'Carousel --cards must contain 2–10 complete image/video cards with one media source per card.', + 'cards', + ) + const source = (media: { + url?: string | undefined + base64?: string | undefined + mimeType?: string | undefined + }): InteractiveMediaSource => { + const mime = media.mimeType === undefined ? {} : {mimeType: media.mimeType} + return media.url === undefined + ? {base64: media.base64!, ...mime} + : {url: media.url, ...mime} + } + const cards: CarouselCard[] = parsed.data.map((card) => { + const buttons: MessageButton[] = card.buttons.map((button) => + button.type === 'catalog' + ? { + type: button.type, + text: button.text, + businessPhoneNumber: button.businessPhoneNumber, + ...(button.catalogProductId === undefined + ? {} + : {catalogProductId: button.catalogProductId}), + } + : button, + ) + const common = { + body: card.body, + buttons, + ...(card.footer === undefined ? {} : {footer: card.footer}), + } + return 'image' in card + ? {...common, image: source(card.image)} + : { + ...common, + video: { + ...source(card.video), + ...(card.video.thumbnail === undefined + ? {} + : {thumbnail: card.video.thumbnail}), + }, + } + }) + const ids = new Set() + for (const card of cards) { + validateLinkedButtons(card.buttons, false) + for (const button of card.buttons) + if (button.type === 'reply') { + if (ids.has(button.id)) + throw new PolymorfaConfigurationError( + 'Carousel reply IDs must be unique across all cards.', + 'cards', + ) + ids.add(button.id) + } + } + return cards +} diff --git a/test/commands/message-sends.test.ts b/test/commands/message-sends.test.ts index 56dcd5f4..cd84de62 100644 --- a/test/commands/message-sends.test.ts +++ b/test/commands/message-sends.test.ts @@ -732,7 +732,7 @@ describe('typed message send commands', () => { }) }) - test('rejects reply and CTA mixtures before transport', async () => { + test('rejects replies mixed with copy buttons before transport', async () => { server = await messageServer() const result = await send([ 'message', @@ -777,6 +777,112 @@ describe('typed message send commands', () => { expect(server.requests).toHaveLength(0) }) + test('sends mixed reply, URL and call buttons with a media header once', async () => { + server = await messageServer() + const result = await send([ + 'message', + 'send', + 'buttons', + '--session', + 'session_1', + '--to', + '730000000001', + '--body', + 'Offer', + '--button', + 'reply:stable=Choose', + '--button', + 'url:Open=https://example.com', + '--button', + 'call:Call=+12025550123', + '--media-type', + 'image', + '--url', + 'https://example.com/image.png', + '--idempotency-key', + 'mixed-once', + '--json', + ]) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(sentBody()).toMatchObject({ + content: { + buttons: { + media: {type: 'image', url: 'https://example.com/image.png'}, + buttons: [ + {type: 'reply', id: 'stable'}, + {type: 'url'}, + {type: 'call'}, + ], + }, + }, + }) + expect(server.requests[0]!.headers['idempotency-key']).toBe('mixed-once') + }) + + test.each([2, 10])('sends all %i cards as one request', async (count) => { + const cards = Array.from({length: count}, (_, index) => ({ + body: `Card ${index}`, + image: {url: 'https://example.com/image.png'}, + buttons: [{type: 'reply', text: 'Choose', id: `card-${index}`}], + })) + const file = join(directory, 'cards.json') + await writeFile(file, JSON.stringify(cards)) + server = await messageServer() + const result = await send([ + 'message', + 'send', + 'carousel', + '--session', + 'session_1', + '--to', + '730000000001', + '--body', + 'Offers', + '--cards', + file, + '--json', + ]) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(sentBody()).toMatchObject({ + content: {carousel: {body: 'Offers', cards}}, + }) + }) + + test('rejects malformed and duplicate carousel cards before HTTP', async () => { + const card = { + body: 'Offer', + image: {url: 'https://example.com/image.png'}, + buttons: [{type: 'reply', text: 'Choose', id: 'same'}], + } + const file = join(directory, 'cards.json') + server = await messageServer() + for (const cards of [ + [card], + [card, card], + [{...card, video: {base64: 'c3ludGhldGlj'}}, card], + ]) { + await writeFile(file, JSON.stringify(cards)) + const result = await send([ + 'message', + 'send', + 'carousel', + '--session', + 'session_1', + '--to', + '730000000001', + '--body', + 'Offers', + '--cards', + file, + '--json', + ]) + expect(exitCode(result.error)).toBe(2) + } + expect(server.requests).toHaveLength(0) + }) + test('groups list rows into sections', async () => { server = await messageServer() From f8767a9bf3073f5ff00cdde5d98ffd918c6868ff Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 14 Oct 2026 23:38:14 +0300 Subject: [PATCH 3/4] docs: record interactive SDK dependency and publication hold --- contracts/interactive-messages.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 contracts/interactive-messages.json diff --git a/contracts/interactive-messages.json b/contracts/interactive-messages.json new file mode 100644 index 00000000..1f1e952d --- /dev/null +++ b/contracts/interactive-messages.json @@ -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." +} From a4280c539bafefc0f026c1bda8796d2b64314d35 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 14 Oct 2026 23:39:18 +0300 Subject: [PATCH 4/4] fix: hold interactive CLI release for matching accepted SDK --- scripts/prepare-release.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index f124c2b2..8c9c0f68 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -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',