From 726b63bf7db987ac270fd92fd825f95cc71cef9f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 10:51:50 +0300 Subject: [PATCH 1/2] Add Official API round-2 CLI commands pending SDK prerelease --- contracts/official-api-round2.json | 115 +++++++++++++++ docs/official-api-round2.md | 66 +++++++++ scripts/prepare-release.mjs | 1 + src/commands/cloud/catalog/get.ts | 27 ++++ src/commands/cloud/catalog/list.ts | 25 +++- src/commands/cloud/catalog/products.ts | 34 ++++- src/commands/cloud/catalog/sync.ts | 24 +++ src/commands/cloud/group/picture.ts | 57 ++++++++ src/commands/cloud/marketing/status.ts | 25 +++- src/commands/contact/block.ts | 3 +- src/commands/contact/unblock.ts | 3 +- src/commands/group/picture.ts | 58 ++++++++ src/commands/webhook/create.ts | 10 +- src/commands/webhook/update.ts | 18 ++- src/developer/batching.ts | 64 ++++++++ src/developer/render.ts | 13 ++ test/commands/developer-webhooks.test.ts | 37 ++++- test/commands/official-api-round2.test.ts | 169 ++++++++++++++++++++++ test/developer/render.test.ts | 5 + 19 files changed, 727 insertions(+), 27 deletions(-) create mode 100644 contracts/official-api-round2.json create mode 100644 docs/official-api-round2.md create mode 100644 src/commands/cloud/catalog/get.ts create mode 100644 src/commands/cloud/catalog/sync.ts create mode 100644 src/commands/cloud/group/picture.ts create mode 100644 src/commands/group/picture.ts create mode 100644 src/developer/batching.ts create mode 100644 test/commands/official-api-round2.test.ts diff --git a/contracts/official-api-round2.json b/contracts/official-api-round2.json new file mode 100644 index 00000000..14991b9d --- /dev/null +++ b/contracts/official-api-round2.json @@ -0,0 +1,115 @@ +{ + "repository": "polymorfa/polymorfa", + "sourceCommit": "9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f", + "family": "messaging", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "6d5b5477a86a85504f921933bf7b26f2c47506e21380fe73c8a5ff1002163b28", + "published": false, + "note": "Prepared beta counterparts. Exact merged sources below remain authoritative. No staging/production acceptance or package publication. Reconcile full release source, spec coverage and all feature pins before publication. Private Console routes are excluded.", + "items": [ + { + "item": "OA-03", + "mergeSha": "682b9980a19de784b9ada565a97440a8b7fb05fb", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "cfa88b9f4aea0cd2d16347d33818f6a3f5870fa0cb376dd6a97103e6b677db2a", + "releasePath": "internal/docs/releases/2026-10-11-cloud-onboarding-readiness.md", + "releaseSha256": "1cb94749b857cce638b81aecbf34f5dfaf1079fca94bdb1632e24069b47463d0", + "snapshotPath": "contracts/official-api-round2/oa-03.json", + "snapshotSha256": "62641435f1d670607d5b91e8363f42fdc45dc7d669c2e3a8c461c2a01abe337c", + "operationIds": ["getQuickLink", "cancelQuickLink"] + }, + { + "item": "OA-16", + "mergeSha": "7391c22e50d6e238771f3428c22611e100f9a13e", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "04644a0e0fd11b81d457a7f5efb55648567e382953aa2cb93fc7f7250aeff16a", + "releasePath": "internal/docs/releases/2026-10-11-cloud-catalog-sync.md", + "releaseSha256": "dfc1a16d31be23926a1440b5ded807251ba9ca4a7b732c99152ffd0d744aa30f", + "snapshotPath": "contracts/official-api-round2/oa-16.json", + "snapshotSha256": "2bc4fbba821e9c3c90b7a9f632aace0360edbcaf8a1685b9b6a4d9387b5d305d", + "operationIds": [ + "listCloudCatalogs", + "getCloudCatalog", + "syncCloudCatalog", + "listCloudCatalogProducts" + ] + }, + { + "item": "OA-17", + "mergeSha": "24ac44aad59715a3f32dcbee2f4fe5cb07bbda87", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "5ce4bfda4d9ac8ca29b72a9eff6cf009cc0ee0a8be0167e820d3c851ca3e6f8e", + "releasePath": "internal/docs/releases/2026-10-11-cloud-marketing-messages.md", + "releaseSha256": "088feba9c5dbb933f7a8f159ea7f9f1c6489fbcf70da82277c1f3bd7f04695f4", + "snapshotPath": "contracts/official-api-round2/oa-17.json", + "snapshotSha256": "84f218fa062e92c5792e9f075d1049b26f314634412da2db8b49e195d53d3dee", + "operationIds": ["getMarketingMessagesStatus"] + }, + { + "item": "OA-18", + "mergeSha": "e092ffc559b0bd814931560059e07983c6915f66", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "7520f63ea6f8b6ca02465b4548c44b5001b0b0b0fadde331d06c4b497bc0153c", + "releasePath": "internal/docs/releases/2026-10-16-oa18-parent-bsuid-block.md", + "releaseSha256": "aea935f32779f2c4d32ca95427746203b5d51cca63f07e4d5454906d6acbe28a", + "snapshotPath": "contracts/official-api-round2/oa-18.json", + "snapshotSha256": "a381304c948807df9ec410617e93ac827073978f091708aac134fe23b7c68f0d", + "operationIds": ["blockContact", "unblockContact"] + }, + { + "item": "OA-21", + "mergeSha": "ceb2d0227e5d0aa21e390508d9437e088794da1f", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "9d8faf13be970bee800ad15e308c130bc4ae8703865ae99278b9ae315cc27638", + "releasePath": "internal/docs/releases/2026-10-01-official-groups.md", + "releaseSha256": "72efd0f27256dd1c7ee04a26e325b6a9051c4a8453d0b554dbce14241fb662d4", + "snapshotPath": "contracts/official-api-round2/oa-21.json", + "snapshotSha256": "ec42f4a70d6d02803eb31864438668f19307b97486a71dcb0c38ac5cc92b13f6", + "operationIds": ["setOfficialGroupPicture"] + }, + { + "item": "OA-30", + "mergeSha": "d1985700bf1738fe2c4fd55b17a1e6d7fd328453", + "sourcePath": "apps/api/docs/openapi.management.json", + "sourceSha256": "e28256daac1e8efd84998d7399b74c3200623c19ac807a4b0730523a667976ff", + "releasePath": "internal/docs/releases/2026-10-16-webhook-batching.md", + "releaseSha256": "98c411776b87bcbf6fe6a77b7657472ce1ced305a427e939946454a2f8acce37", + "snapshotPath": "contracts/official-api-round2/oa-30.json", + "snapshotSha256": "b6885a304b5d29d14e06c7b6281f1b55d04041f44a288f6754355b8ae67d01f4", + "operationIds": [ + "listOrganizationWebhooks", + "createOrganizationWebhook", + "getOrganizationWebhook", + "updateOrganizationWebhook", + "deleteOrganizationWebhook", + "rotateOrganizationWebhookSecret", + "testOrganizationWebhook", + "listProjectWebhooks", + "createProjectWebhook", + "getProjectWebhook", + "updateProjectWebhook", + "deleteProjectWebhook", + "rotateProjectWebhookSecret", + "testProjectWebhook" + ] + } + ], + "errorCodes": [ + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", + "marketing_disabled_on_cloud_api", + "parent_bsuid_not_supported", + "hybrid_group_linked_devices_unavailable", + "catalog_cache_full", + "catalog_sync_cooldown" + ], + "checkedDev": "dafb7cb4e4efbcdcbd04af206f1ef6d47c209379", + "sdk": { + "pullRequest": "https://github.com/polymorfa/sdks/pull/360", + "sourceCommit": "7eaf8796257a3cc908ea4648de125e689f66fd62", + "registryVersion": null, + "published": false, + "note": "Wait for an accepted containing SDK prerelease. No Git or file dependency is committed. Candidate local verification only." + } +} diff --git a/docs/official-api-round2.md b/docs/official-api-round2.md new file mode 100644 index 00000000..0d9a2e9d --- /dev/null +++ b/docs/official-api-round2.md @@ -0,0 +1,66 @@ +# Official API round-2 commands + +These commands prepare flagged betas. Enrollment, an active connection and the +required scopes remain server requirements. `feature_unavailable` remains an +authorization error with exit code 3 and a structured error on stderr with +`--json`. No command grants access or claims GA availability. + +The CLI PR waits for an accepted SDK prerelease containing these methods. +`contracts/official-api-round2.json` records the exact merged backend sources and +a publication hold. The committed SDK dependency remains its existing registry +prerelease until the containing package is available. A local candidate build is +verification evidence only. + +```sh +polymorfa cloud catalog list --session NUMBER_ID --json +polymorfa cloud catalog get --session NUMBER_ID --catalog CATALOG_ID --json +polymorfa cloud catalog sync --session NUMBER_ID --catalog CATALOG_ID --json +polymorfa cloud catalog products --session NUMBER_ID --catalog CATALOG_ID --limit 50 --json +polymorfa cloud marketing status --session NUMBER_ID --json +polymorfa contact block CONTACT_ID --session NUMBER_ID --json +polymorfa contact unblock CONTACT_ID --session NUMBER_ID --json +polymorfa cloud group picture GROUP_ID --session OFFICIAL_NUMBER_ID --file picture.jpg --json +polymorfa group picture GROUP_ID --session HYBRID_NUMBER_ID --file picture.jpg --json +polymorfa webhook update WEBHOOK_ID --owner organization --batching on --batch-max-events 100 --batch-window-ms 1000 --json +polymorfa webhook update WEBHOOK_ID --owner organization --batching off --json +``` + +Replace the uppercase IDs with your resource IDs. Choose your existing regional +Messaging base URL and credentials using the ordinary profile/environment +settings. Native catalog reads use saved snapshots; only sync calls Meta. Sync +runs once and may take 50 seconds; its default timeout is 60 seconds. A new sync +inside 120 seconds returns `catalog_sync_cooldown` with Retry-After. Product +pages accept the returned `nextCursor` via `--cursor`. Existing Graph catalog +commands still use `--waba`, `--graph-version` and `--after`; these cannot be +mixed with a native `--session` target. + +Marketing status reports onboarding, terms, raw provider values and next action. +Unknown status never proves eligibility. Terms are accepted in WhatsApp Manager. +The existing Graph form remains `cloud marketing status --waba WABA_ID +--graph-version VERSION`. + +Native block/unblock uses public contact IDs, including identities the API +resolves to an ordinary BSUID or phone. Parent BSUIDs are metadata only and +cannot be targets. An uncertain provider effect must be checked before a new +attempt. Reverting a CLI change does not undo an applied block or picture. + +`cloud group picture` uploads one multipart JPEG for an Official-only Number: +square, at least 192 x 192 pixels, at most 5 MB. The backend validates the image. +Hybrid Numbers use `group picture` on the Linked Devices connection only. +`hybrid_group_linked_devices_unavailable` means not executed. Never route the +same operation through Official as recovery. Group history remains available +through existing `chat` commands with opt-in regional history access. + +Batching applies to Platform organization/project webhook endpoints in native +format. It defaults off. Configure the receiver to verify and handle both +single events and the version 1 `event_batch` envelope before enabling it. +Deduplicate member event IDs and batch IDs; acknowledge the whole request only. +The event limit is 2-100, but a window can emit one event. The window is +100-1000 ms. Omitted settings preserve the endpoint's saved values on update. +The server checks saved format and enrollment. Disabling remains available after +withdrawal. `webhook view` shows saved batching settings. + +Private Console routes are excluded. No live Meta, hosted webhook receiver, +regional deployment, package publication or customer acceptance is established +by these command examples. Matching staging acceptance gates prereleases; +production acceptance separately gates stable publication. diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index f124c2b2..edbee4a2 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -29,6 +29,7 @@ try { ) validateSdk(sdk.provenance, sdk.version, channel) validateFeatureContractPins(sdk.provenance, [ + JSON.parse(readFileSync('contracts/official-api-round2.json')), JSON.parse(readFileSync('contracts/abprops-capabilities.json')), ]) const files = execFileSync('git', [ diff --git a/src/commands/cloud/catalog/get.ts b/src/commands/cloud/catalog/get.ts new file mode 100644 index 00000000..779813a1 --- /dev/null +++ b/src/commands/cloud/catalog/get.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +export default class CloudCatalogGet extends BaseCommand { + static override description = + 'Read a cached catalog and its latest sync outcome (beta, business:read). Enrollment applies; feature_unavailable is preserved.' + static override flags = { + session: Flags.string({ + description: 'Number ID with an active Official connection.', + required: true, + }), + catalog: Flags.string({ + description: 'Catalog ID, 1 to 32 digits.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudCatalogGet) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.catalogs.retrieve( + flags.session, + flags.catalog, + ) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/catalog/list.ts b/src/commands/cloud/catalog/list.ts index c6978f33..73c6d978 100644 --- a/src/commands/cloud/catalog/list.ts +++ b/src/commands/cloud/catalog/list.ts @@ -4,15 +4,20 @@ import {BaseCommand} from '../../../base-command.js' export default class CloudCatalogList extends BaseCommand { static override description = - 'List visible Meta product catalogs for a WhatsApp Business Account. Requires sessions:read and Graph access.' + 'List cached Number catalogs (beta, business:read), or visible WABA catalogs with Graph access. Enrollment applies; feature_unavailable is preserved.' static override flags = { + session: Flags.string({ + description: + 'Number ID for the native catalog cache beta. Reads never refresh Meta.', + exclusive: ['waba', 'graph-version', 'limit', 'after'], + }), waba: Flags.string({ description: 'WhatsApp Business Account ID.', - required: true, + exclusive: ['session'], }), 'graph-version': Flags.string({ description: 'Meta Graph version, for example v26.0.', - required: true, + exclusive: ['session'], }), limit: Flags.integer({ description: 'Page size from 1 to 100; API default is 25.', @@ -25,15 +30,23 @@ export default class CloudCatalogList extends BaseCommand { } async run(): Promise { const {flags} = await this.parse(CloudCatalogList) - if (!flags.waba.trim() || !flags['graph-version'].trim()) + if ( + flags.session === undefined && + (!flags.waba?.trim() || !flags['graph-version']?.trim()) + ) throw new PolymorfaConfigurationError( 'Provide a WABA ID and Graph version.', ) const client = await this.messagingClient(flags, { serverCredentialOnly: 'Official API', }) - const response = await client.cloudCatalogs.list(flags.waba, { - version: flags['graph-version'], + if (flags.session !== undefined) { + const response = await client.catalogs.list(flags.session) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) + return + } + const response = await client.cloudCatalogs.list(flags.waba!, { + version: flags['graph-version']!, ...(flags.limit === undefined ? {} : {limit: flags.limit}), ...(flags.after === undefined ? {} : {after: flags.after}), }) diff --git a/src/commands/cloud/catalog/products.ts b/src/commands/cloud/catalog/products.ts index 79c1cd4a..3fb265f4 100644 --- a/src/commands/cloud/catalog/products.ts +++ b/src/commands/cloud/catalog/products.ts @@ -4,12 +4,20 @@ import {BaseCommand} from '../../../base-command.js' export default class CloudCatalogProducts extends BaseCommand { static override description = - 'List products in a catalog linked to an Official API WABA (beta). Requires sessions:read and Graph access.' + 'List cached Number products (beta, business:read), or linked WABA products with Graph access. Enrollment applies; feature_unavailable is preserved.' static override flags = { + session: Flags.string({ + description: 'Number ID for cached products (beta, business:read).', + exclusive: ['waba', 'graph-version', 'after'], + }), + cursor: Flags.string({ + description: 'Native nextCursor from the previous products response.', + dependsOn: ['session'], + }), waba: Flags.string({ description: 'WhatsApp Business Account ID.', - required: true, + exclusive: ['session'], }), catalog: Flags.string({ description: 'Numeric product catalog ID linked to the WABA.', @@ -17,7 +25,7 @@ export default class CloudCatalogProducts extends BaseCommand { }), 'graph-version': Flags.string({ description: 'Meta Graph version, for example v26.0.', - required: true, + exclusive: ['session'], }), limit: Flags.integer({ description: 'Page size from 1 to 100; API default is 25.', @@ -32,9 +40,9 @@ export default class CloudCatalogProducts extends BaseCommand { async run(): Promise { const {flags} = await this.parse(CloudCatalogProducts) if ( - !flags.waba.trim() || + (flags.session === undefined && !flags.waba?.trim()) || !/^[0-9]+$/.test(flags.catalog) || - !flags['graph-version'].trim() + (flags.session === undefined && !flags['graph-version']?.trim()) ) throw new PolymorfaConfigurationError( 'Provide a WABA ID, numeric catalog ID, and Graph version.', @@ -42,11 +50,23 @@ export default class CloudCatalogProducts extends BaseCommand { const client = await this.messagingClient(flags, { serverCredentialOnly: 'Official API', }) + if (flags.session !== undefined) { + const response = await client.catalogs.listProducts( + flags.session, + flags.catalog, + { + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + }, + ) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) + return + } const response = await client.cloudCatalogs.listProducts( - flags.waba, + flags.waba!, flags.catalog, { - version: flags['graph-version'], + version: flags['graph-version']!, ...(flags.limit === undefined ? {} : {limit: flags.limit}), ...(flags.after === undefined ? {} : {after: flags.after}), }, diff --git a/src/commands/cloud/catalog/sync.ts b/src/commands/cloud/catalog/sync.ts new file mode 100644 index 00000000..bc5263f7 --- /dev/null +++ b/src/commands/cloud/catalog/sync.ts @@ -0,0 +1,24 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +export default class CloudCatalogSync extends BaseCommand { + static override description = + 'Explicitly refresh a catalog from Meta (beta, business:read). Sends once; sync cooldown is 120 seconds. A failure may preserve the previous snapshot. Enrollment applies; feature_unavailable is preserved.' + static override flags = { + session: Flags.string({ + description: 'Number ID with an active Official connection.', + required: true, + }), + catalog: Flags.string({ + description: 'Catalog ID, 1 to 32 digits.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudCatalogSync) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.catalogs.sync(flags.session, flags.catalog) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/group/picture.ts b/src/commands/cloud/group/picture.ts new file mode 100644 index 00000000..039684a9 --- /dev/null +++ b/src/commands/cloud/group/picture.ts @@ -0,0 +1,57 @@ +import {Args, Flags} from '@oclif/core' +import {open} from 'node:fs/promises' +import {PolymorfaValidationError} from '@polymorfa/sdk' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' +export default class CloudGroupPicture extends BaseCommand { + static override description = + 'Replace an Official-only group picture (beta, groups:manage). JPEG, square, at least 192 x 192 pixels, at most 5 MB. Hybrid Numbers use group picture through Linked Devices only; no Official fallback.' + static override args = { + group: Args.string({description: 'Group conversation ID.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Official-only Number ID.', + required: true, + }), + file: Flags.string({ + description: 'Local JPEG file; uploaded as multipart bytes.', + required: true, + }), + 'idempotency-key': Flags.string({ + description: 'Fence an explicit retry after checking the outcome.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudGroupPicture) + const handle = await open(flags.file, 'r') + let bytes: Buffer + try { + const stat = await handle.stat() + if (!stat.isFile() || stat.size === 0 || stat.size > 5 * 1024 * 1024) + throw new PolymorfaValidationError( + 'Provide a JPEG file of 1 byte to 5 MB.', + ) + bytes = Buffer.alloc(stat.size + 1) + const {bytesRead} = await handle.read(bytes, 0, bytes.length, 0) + if (bytesRead !== stat.size) + throw new PolymorfaValidationError( + 'Picture changed during reading; try again after the file is stable.', + ) + bytes = bytes.subarray(0, bytesRead) + } finally { + await handle.close() + } + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.officialGroups.setPicture( + flags.session, + args.group, + new Blob([Uint8Array.from(bytes)], {type: 'image/jpeg'}), + requestKey(flags['idempotency-key']), + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/marketing/status.ts b/src/commands/cloud/marketing/status.ts index 2a257da7..5840bab8 100644 --- a/src/commands/cloud/marketing/status.ts +++ b/src/commands/cloud/marketing/status.ts @@ -4,31 +4,44 @@ import {BaseCommand} from '../../../base-command.js' export default class CloudMarketingStatus extends BaseCommand { static override description = - 'Read raw Marketing Messages WABA status strings (beta). Requires sessions:read and Graph access.' + 'Read Number Marketing Messages onboarding and terms (beta), or raw WABA fields with Graph access. Requires sessions:read; feature_unavailable is preserved.' static override flags = { + session: Flags.string({ + description: + 'Number ID for the native Marketing Messages beta. Terms are accepted in WhatsApp Manager.', + exclusive: ['waba', 'graph-version'], + }), waba: Flags.string({ description: 'WhatsApp Business Account ID from an Official API Number in your project.', - required: true, + exclusive: ['session'], }), 'graph-version': Flags.string({ description: 'Meta Graph version, for example v26.0.', - required: true, + exclusive: ['session'], }), } async run(): Promise { const {flags} = await this.parse(CloudMarketingStatus) - if (!flags.waba.trim() || !flags['graph-version'].trim()) + if ( + flags.session === undefined && + (!flags.waba?.trim() || !flags['graph-version']?.trim()) + ) throw new PolymorfaConfigurationError( 'Provide a WABA ID and Graph version.', ) const client = await this.messagingClient(flags, { serverCredentialOnly: 'Official API', }) - const response = await client.cloudMarketing.status(flags.waba, { - version: flags['graph-version'], + if (flags.session !== undefined) { + const response = await client.cloudMarketing.getStatus(flags.session) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) + return + } + const response = await client.cloudMarketing.status(flags.waba!, { + version: flags['graph-version']!, }) this.writeValue(response, JSON.stringify(response.data, null, 2), flags) } diff --git a/src/commands/contact/block.ts b/src/commands/contact/block.ts index 414ee0d7..1b5a1f0d 100644 --- a/src/commands/contact/block.ts +++ b/src/commands/contact/block.ts @@ -11,7 +11,8 @@ export default class ContactBlock extends BaseCommand { }), } - static override description = 'Block a contact.' + static override description = + 'Block a contact by public ID. Official API beta supports phone/BSUID identities resolved by the API; parent BSUIDs cannot be targets. feature_unavailable is preserved.' static override flags = { 'idempotency-key': Flags.string({ diff --git a/src/commands/contact/unblock.ts b/src/commands/contact/unblock.ts index 34240e46..96eb20da 100644 --- a/src/commands/contact/unblock.ts +++ b/src/commands/contact/unblock.ts @@ -11,7 +11,8 @@ export default class ContactUnblock extends BaseCommand { }), } - static override description = 'Unblock a contact.' + static override description = + 'Unblock a contact by public ID. Official API beta supports phone/BSUID identities resolved by the API; parent BSUIDs cannot be targets. feature_unavailable is preserved.' static override flags = { 'idempotency-key': Flags.string({ diff --git a/src/commands/group/picture.ts b/src/commands/group/picture.ts new file mode 100644 index 00000000..f6c36846 --- /dev/null +++ b/src/commands/group/picture.ts @@ -0,0 +1,58 @@ +import {Args, Flags} from '@oclif/core' +import {readFile, stat} from 'node:fs/promises' +import {PolymorfaValidationError} from '@polymorfa/sdk' +import {BaseCommand} from '../../base-command.js' +import {idempotencyOptions} from '../../input/message-send.js' +export default class GroupPicture extends BaseCommand { + static override description = + 'Replace a Linked Devices group picture. Hybrid Numbers always use Linked Devices; hybrid_group_linked_devices_unavailable means not executed. Never fall back to Official.' + static override args = { + group: Args.string({description: 'Group conversation ID.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Linked Devices or Hybrid Number ID.', + required: true, + }), + url: Flags.string({ + description: 'Picture URL fetched by the Messaging service.', + exclusive: ['file'], + }), + file: Flags.string({ + description: 'Local picture, at most 5 MB, sent as base64 JSON.', + exclusive: ['url'], + }), + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this change.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(GroupPicture) + if (flags.url === undefined && flags.file === undefined) + throw new PolymorfaValidationError('Provide --file or --url.') + let body: {url?: string; base64?: string} = {} + if (flags.file !== undefined) { + const info = await stat(flags.file) + if (!info.isFile() || info.size === 0 || info.size > 5 * 1024 * 1024) + throw new PolymorfaValidationError( + 'Picture must be a nonempty file of at most 5 MB.', + ) + const bytes = await readFile(flags.file) + if (bytes.length > 5 * 1024 * 1024) + throw new PolymorfaValidationError('Picture exceeds 5 MB.') + body = {base64: bytes.toString('base64')} + } else { + body = {url: flags.url!} + } + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Groups', + }) + const response = await client.groups.setPicture( + flags.session, + args.group, + body, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue(response, `Updated group ${args.group} picture.`, flags) + } +} diff --git a/src/commands/webhook/create.ts b/src/commands/webhook/create.ts index bcca62f2..068c0317 100644 --- a/src/commands/webhook/create.ts +++ b/src/commands/webhook/create.ts @@ -1,3 +1,4 @@ +import {batchingFlags, withBatching} from '../../developer/batching.js' import {Flags} from '@oclif/core' import type {CreateProjectWebhookInput} from '@polymorfa/sdk' @@ -15,9 +16,11 @@ import { } from '../../developer/phone-offline.js' export default class WebhookCreate extends BaseCommand { - static override description = 'Create a webhook from one JSON object.' + static override description = + 'Create a webhook from one JSON object. Batching is an enrolled beta for native-format endpoints; prepare the receiver before enabling it.' static override flags = { + ...batchingFlags, 'idempotency-key': idempotencyFlag, input: Flags.string({ description: 'Read the webhook JSON object from a file or stdin (-).', @@ -48,7 +51,10 @@ export default class WebhookCreate extends BaseCommand { } } const session = await this.developerSession(flags) - const input = merged as unknown as CreateProjectWebhookInput + const input = withBatching( + merged, + flags, + ) as unknown as CreateProjectWebhookInput const response = await session.client.webhooks.create(input, { ...session.requestOptions, idempotencyKey: idempotencyKey(flags['idempotency-key']), diff --git a/src/commands/webhook/update.ts b/src/commands/webhook/update.ts index 4d4f6de7..f096c7db 100644 --- a/src/commands/webhook/update.ts +++ b/src/commands/webhook/update.ts @@ -1,3 +1,4 @@ +import {batchingFlags, withBatching} from '../../developer/batching.js' import {Args, Flags} from '@oclif/core' import {readJsonInput} from '../../api/request.js' @@ -16,9 +17,11 @@ export default class WebhookUpdate extends BaseCommand { webhook: Args.string({description: 'Webhook ID.', required: true}), } - static override description = 'Update a webhook without rotating its secret.' + static override description = + 'Update a webhook without rotating its secret. Batching is an enrolled native-format beta; off remains available after withdrawal.' static override flags = { + ...batchingFlags, 'idempotency-key': idempotencyFlag, input: Flags.string({ description: @@ -30,8 +33,16 @@ export default class WebhookUpdate extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(WebhookUpdate) const startDay = flags['phone-offline-start-day'] - if (flags.input === undefined && startDay === undefined) { - this.error('Provide --input or --phone-offline-start-day.', {exit: 2}) + if ( + flags.input === undefined && + startDay === undefined && + flags.batching === undefined && + flags['batch-max-events'] === undefined && + flags['batch-window-ms'] === undefined + ) { + this.error('Provide --input, --phone-offline-start-day or --batching.', { + exit: 2, + }) } let body: Readonly> = flags.input === undefined @@ -55,6 +66,7 @@ export default class WebhookUpdate extends BaseCommand { this.error((error as Error).message, {exit: 2}) } } + body = withBatching(body, flags) const response = await session.client.webhooks.update(args.webhook, body, { ...session.requestOptions, idempotencyKey: idempotencyKey(flags['idempotency-key']), diff --git a/src/developer/batching.ts b/src/developer/batching.ts new file mode 100644 index 00000000..971e8e8a --- /dev/null +++ b/src/developer/batching.ts @@ -0,0 +1,64 @@ +import {Flags} from '@oclif/core' +import { + PolymorfaValidationError, + type WebhookBatchingInput, +} from '@polymorfa/sdk' + +export const batchingFlags = { + batching: Flags.string({ + options: ['on', 'off'], + description: + 'Opt in to native webhook batching (beta), or disable it. Enabling requires enrollment; feature_unavailable is preserved.', + }), + 'batch-max-events': Flags.integer({ + min: 2, + max: 100, + description: + 'Batch event limit, 2-100 (default 100). A window may contain one event.', + }), + 'batch-window-ms': Flags.integer({ + min: 100, + max: 1000, + description: 'Batch window, 100-1000 ms (default 1000).', + }), +} +export interface BatchingFlags { + readonly batching?: string | undefined + readonly 'batch-max-events'?: number | undefined + readonly 'batch-window-ms'?: number | undefined +} +export function withBatching( + body: Readonly>, + flags: BatchingFlags, +): Readonly> { + const enabled = flags.batching + const maxEvents = flags['batch-max-events'] + const windowMs = flags['batch-window-ms'] + if ( + enabled === undefined && + maxEvents === undefined && + windowMs === undefined + ) + return body + const existing = body.batching + if ( + existing !== undefined && + (typeof existing !== 'object' || + existing === null || + Array.isArray(existing)) + ) + throw new PolymorfaValidationError('batching input must be an object.') + const previous = (existing ?? {}) as Record + const selected = enabled === undefined ? previous.enabled : enabled === 'on' + if (typeof selected !== 'boolean') + throw new PolymorfaValidationError( + 'Provide --batching on|off or batching.enabled in --input.', + ) + const batching: WebhookBatchingInput = { + ...previous, + enabled: selected, + ...(maxEvents === undefined ? {} : {maxEvents}), + ...(windowMs === undefined ? {} : {windowMs}), + } + return {...body, batching} +} diff --git a/src/developer/render.ts b/src/developer/render.ts index e1526fac..10627deb 100644 --- a/src/developer/render.ts +++ b/src/developer/render.ts @@ -56,6 +56,11 @@ export function renderWebhookDetail(webhook: { readonly eventTypes: readonly string[] readonly headers: readonly {readonly name: string}[] readonly phoneOfflineStartDay?: number + readonly batching?: { + readonly enabled: boolean + readonly maxEvents: number + readonly windowMs: number + } readonly createdAt: string readonly updatedAt: string }): string { @@ -68,6 +73,14 @@ export function renderWebhookDetail(webhook: { ['Format', webhook.format], ['Event types', webhook.eventTypes.join(', ')], ['Phone offline warning', phoneOfflineWarning(webhook)], + [ + 'Batching (beta)', + webhook.batching === undefined + ? 'not reported' + : webhook.batching.enabled + ? `on, up to ${webhook.batching.maxEvents} events, ${webhook.batching.windowMs} ms` + : 'off', + ], ['Header names', webhook.headers.map(({name}) => name).join(', ')], ['Created', webhook.createdAt], ['Updated', webhook.updatedAt], diff --git a/test/commands/developer-webhooks.test.ts b/test/commands/developer-webhooks.test.ts index b79c1e4c..7eb2e3f3 100644 --- a/test/commands/developer-webhooks.test.ts +++ b/test/commands/developer-webhooks.test.ts @@ -495,6 +495,41 @@ describe('developer webhook commands', () => { }, ) + test('updates native batching settings without input and can disable after withdrawal', async () => { + stdout() + await developerEnvironment(() => + WebhookUpdate.run([ + 'wh_1', + '--owner', + 'organization', + '--batching', + 'on', + '--batch-max-events', + '2', + '--batch-window-ms', + '100', + '--json', + ]), + ) + expect(findCall('webhooks.update').args[1]).toEqual({ + batching: {enabled: true, maxEvents: 2, windowMs: 100}, + }) + state.calls.splice(0) + await developerEnvironment(() => + WebhookUpdate.run([ + 'wh_1', + '--owner', + 'organization', + '--batching', + 'off', + '--json', + ]), + ) + expect(findCall('webhooks.update').args[1]).toEqual({ + batching: {enabled: false}, + }) + }) + test('requires an input or a start day for updates', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -504,7 +539,7 @@ describe('developer webhook commands', () => { ).catch((caught: unknown) => caught) expect(exitCode(error)).toBe(2) expect(String(stderr.mock.calls.flat().join(''))).toContain( - 'Provide --input or --phone-offline-start-day.', + 'Provide --input, --phone-offline-start-day or --batching.', ) expect(state.calls).toHaveLength(0) }) diff --git a/test/commands/official-api-round2.test.ts b/test/commands/official-api-round2.test.ts new file mode 100644 index 00000000..b44f8049 --- /dev/null +++ b/test/commands/official-api-round2.test.ts @@ -0,0 +1,169 @@ +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +describe('Official API round-2 commands', () => { + let server: TestHttpServer | undefined + let directory: string | undefined + afterEach(async () => { + await server?.close() + server = undefined + if (directory) await rm(directory, {recursive: true, force: true}) + directory = undefined + }) + const run = async (command: string) => { + if (!server) throw Error('Server missing') + return withEnvironment( + { + POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(command), + ) + } + test.each([ + ['cloud catalog list --session n/eu', 'GET', '/messaging/n%2Feu/catalogs'], + [ + 'cloud catalog get --session n/eu --catalog 123', + 'GET', + '/messaging/n%2Feu/catalogs/123', + ], + [ + 'cloud catalog sync --session n/eu --catalog 123', + 'POST', + '/messaging/n%2Feu/catalogs/123/sync', + ], + [ + 'cloud catalog products --session n/eu --catalog 123 --limit 2 --cursor next_-', + 'GET', + '/messaging/n%2Feu/catalogs/123/products?limit=2&cursor=next_-', + ], + [ + 'cloud marketing status --session n/eu', + 'GET', + '/messaging/n%2Feu/marketing-messages/status', + ], + [ + 'contact block 123 --session n/eu', + 'POST', + '/messaging/n%2Feu/contacts/123/block', + ], + [ + 'contact unblock 123 --session n/eu', + 'POST', + '/messaging/n%2Feu/contacts/123/unblock', + ], + ])('uses the native route for %s', async (command, method, url) => { + server = await startHttpServer({ + body: {success: true, data: {catalogs: []}}, + }) + const result = await run(command + ' --json') + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({method, url}) + expect(JSON.parse(result.stdout)).toBeTypeOf('object') + }) + test.each([ + 'cloud catalog list --session n', + 'cloud catalog sync --session n --catalog 123', + 'cloud marketing status --session n', + 'webhook update w --owner organization --batching on', + ])( + 'retains feature_unavailable and authorization exit for %s', + async (command) => { + server = await startHttpServer({ + status: 403, + body: { + error: {code: 'feature_unavailable', message: 'Beta unavailable'}, + }, + }) + const result = await run(command + ' --json') + expect(result.error).toBeDefined() + expect( + JSON.parse( + result.stderr + .split('\n') + .find((line) => line.startsWith('{"schemaVersion"'))!, + ), + ).toMatchObject({ + error: {code: 'feature_unavailable'}, + }) + expect((result.error as {oclif: {exit: number}}).oclif.exit).toBe(3) + expect(server.requests).toHaveLength(1) + }, + ) + test('uploads Official group pictures through multipart and keeps Hybrid on the native route', async () => { + const root = join( + homedir(), + '.polymorfa-agent-work', + 'oa-partner-20261021', + 'sdk-cli', + 'test-fixtures', + ) + await mkdir(root, {recursive: true}) + directory = await mkdtemp(join(root, 'picture-')) + const file = join(directory, 'picture.jpg') + await writeFile(file, 'jpeg-fixture') + server = await startHttpServer({ + body: {success: true, data: {accepted: true}}, + }) + let result = await run( + `cloud group picture 123 --session n --file ${file} --json`, + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'PUT', + url: '/messaging/n/official-groups/123/picture', + }) + expect(server.requests[0]!.headers['content-type']).toMatch( + /^multipart\/form-data; boundary=/, + ) + expect(server.requests[0]!.body).toContain('name="file"') + result = await run( + 'group picture 123 --session hybrid --url https://example.invalid/picture.jpg --json', + ) + expect(result.error).toBeUndefined() + expect(server.requests[1]).toMatchObject({ + method: 'PUT', + url: '/messaging/hybrid/groups/123/picture', + }) + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + url: 'https://example.invalid/picture.jpg', + }) + }) + test('refuses incompatible target flags and bad catalog bounds without dispatch', async () => { + server = await startHttpServer({body: {}}) + for (const command of [ + 'cloud catalog list --session n --waba 123', + 'cloud catalog products --session n --catalog 123 --limit 101', + 'cloud catalog sync --session n --catalog ../x', + 'cloud marketing status --session n --graph-version v26.0', + ]) { + expect((await run(command + ' --json')).error).toBeDefined() + } + expect(server.requests).toHaveLength(0) + }) + test('shows beta and routing restrictions in help', async () => { + server = await startHttpServer({body: {}}) + for (const command of [ + 'cloud marketing status', + 'cloud catalog sync', + 'cloud group picture', + 'webhook update', + ]) { + const result = await run(command + ' --help') + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('beta') + } + expect(server.requests).toHaveLength(0) + }) +}) diff --git a/test/developer/render.test.ts b/test/developer/render.test.ts index a5445800..17854b77 100644 --- a/test/developer/render.test.ts +++ b/test/developer/render.test.ts @@ -61,6 +61,7 @@ describe('developer detail renderers', () => { 'Format\tnative', 'Event types\tmessage.received, session.connected', 'Phone offline warning\toff', + 'Batching (beta)\tnot reported', 'Header names\tx-team', 'Created\t2026-09-08T00:00:00Z', 'Updated\t2026-09-08T01:00:00Z', @@ -73,6 +74,7 @@ describe('developer detail renderers', () => { enabled: true, eventTypes: ['session.phone_offline'], format: 'native', + batching: {enabled: true, maxEvents: 50, windowMs: 500}, headers: [], id: 'wh_1', owner: 'project', @@ -83,6 +85,9 @@ describe('developer detail renderers', () => { expect(renderWebhookDetail({...base, phoneOfflineStartDay: 8})).toContain( 'Phone offline warning\tfrom day 8', ) + expect(renderWebhookDetail(base)).toContain( + 'Batching (beta)\ton, up to 50 events, 500 ms', + ) expect(renderWebhookDetail(base)).toContain( 'Phone offline warning\tnot reported', ) From c71cdcd42ecc66fcb26a5ccb3c876d09665ac03d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 10:56:21 +0300 Subject: [PATCH 2/2] Record the exact SDK round-2 candidate revision --- contracts/official-api-round2.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/official-api-round2.json b/contracts/official-api-round2.json index 14991b9d..e379e27b 100644 --- a/contracts/official-api-round2.json +++ b/contracts/official-api-round2.json @@ -107,7 +107,7 @@ "checkedDev": "dafb7cb4e4efbcdcbd04af206f1ef6d47c209379", "sdk": { "pullRequest": "https://github.com/polymorfa/sdks/pull/360", - "sourceCommit": "7eaf8796257a3cc908ea4648de125e689f66fd62", + "sourceCommit": "f9937116b17f9962de8a3eb632c44d0399b05002", "registryVersion": null, "published": false, "note": "Wait for an accepted containing SDK prerelease. No Git or file dependency is committed. Candidate local verification only."