From 77d0e1dfd3c74f6be9dd8fbfdf2ea4edad77436b Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:35:53 +0200 Subject: [PATCH] feat: a whatsapp resource for WhatsApp Business connections --- CHANGELOG.md | 11 + README.md | 1 + src/main/java/com/fopost/sdk/FoPost.java | 8 + .../java/com/fopost/sdk/model/Platforms.java | 4 +- .../fopost/sdk/model/WhatsappBlockResult.java | 6 + .../sdk/model/WhatsappCommerceSettings.java | 4 + .../model/WhatsappEncryptionKeyStatus.java | 4 + .../com/fopost/sdk/model/WhatsappFlow.java | 16 + .../sdk/model/WhatsappFlowJsonResult.java | 9 + .../sdk/model/WhatsappFlowResponse.java | 12 + .../model/WhatsappFlowValidationError.java | 4 + .../com/fopost/sdk/model/WhatsappGroup.java | 15 + .../com/fopost/sdk/model/WhatsappProfile.java | 21 + .../sdk/model/WhatsappSandboxSession.java | 15 + .../fopost/sdk/model/WhatsappTemplate.java | 19 + .../fopost/sdk/resource/WhatsappResource.java | 429 ++++++++++++++++++ .../java/com/fopost/sdk/WhatsappTest.java | 70 +++ 17 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappBlockResult.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappCommerceSettings.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappEncryptionKeyStatus.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappFlow.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappFlowJsonResult.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappFlowResponse.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappFlowValidationError.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappGroup.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappProfile.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappSandboxSession.java create mode 100644 src/main/java/com/fopost/sdk/model/WhatsappTemplate.java create mode 100644 src/main/java/com/fopost/sdk/resource/WhatsappResource.java create mode 100644 src/test/java/com/fopost/sdk/WhatsappTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 250abfd..bae0662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ All notable changes to this project are documented here. The format follows ### Added +- `whatsapp()` for a WhatsApp Business connection: the business profile + (`getProfile`, `updateProfile`, `requestDisplayName`, `setUsername`), message + templates including the platform's own library (`listTemplates`, + `createTemplate`, `importTemplate`, …), groups, blocking, commerce settings and + flows (`createFlow`, `uploadFlowJson`, `publishFlow`, `listFlowResponses`, …), + plus `getAccountEvents`. All need the `accounts` scope. +- `whatsapp().createSandboxSession` and `listSandboxSessions` invite a tester to + the platform-owned WhatsApp test number. Inviting sends a template, so it needs + the `publish` scope. +- `Platforms.WHATSAPP`. + - `accounts()`: `listSlackChannels`, `listSlackMembers`, `getSlackIdentity` and `updateSlackIdentity` (`UpdateSlackIdentityParams`) for a Slack account. A webhook-connected account answers 409 `webhook_connection`. diff --git a/README.md b/README.md index 343931c..8a908d6 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` | | `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` | +| `whatsapp()` | `getProfile`, `updateProfile`, `requestDisplayName`, `setUsername`, `listTemplates`, `listTemplateLibrary`, `getTemplate`, `createTemplate`, `importTemplate`, `updateTemplate`, `deleteTemplate`, `listGroups`, `createGroup`, `getGroup`, `updateGroup`, `deleteGroup`, `getGroupInviteLink`, `resetGroupInviteLink`, `removeGroupParticipants`, `listBlocked`, `blockUsers`, `unblockUsers`, `getCommerceSettings`, `updateCommerceSettings`, `linkCatalog`, `listFlows`, `getFlow`, `createFlow`, `updateFlow`, `deleteFlow`, `uploadFlowJson`, `publishFlow`, `deprecateFlow`, `listFlowResponses`, `getEncryptionKeyStatus`, `setEncryptionKey`, `getAccountEvents`, `listSandboxSessions`, `createSandboxSession` | | `validate()` | `post`, `length`, `media` | `accounts().communities()` covers the X communities an account can post into: `list`, `sync`, diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index 9094803..8654bcf 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -18,6 +18,7 @@ import com.fopost.sdk.resource.PostsResource; import com.fopost.sdk.resource.ValidateResource; import com.fopost.sdk.resource.WebhooksResource; +import com.fopost.sdk.resource.WhatsappResource; import com.fopost.sdk.resource.WorkspacesResource; import java.time.Duration; import java.util.Map; @@ -66,6 +67,7 @@ public final class FoPost { private final AutomationsResource automations; private final MediaResource media; private final AiResource ai; + private final WhatsappResource whatsapp; private final InboxResource inbox; private final AdsResource ads; private final ValidateResource validate; @@ -82,6 +84,7 @@ private FoPost(ApiClient http) { this.automations = new AutomationsResource(http); this.media = new MediaResource(http); this.ai = new AiResource(http); + this.whatsapp = new WhatsappResource(http); this.inbox = new InboxResource(http); this.ads = new AdsResource(http); this.validate = new ValidateResource(http); @@ -124,6 +127,11 @@ public WebhooksResource webhooks() { return webhooks; } + /** WhatsApp Business: templates, flows, groups, blocking and commerce. */ + public WhatsappResource whatsapp() { + return whatsapp; + } + public AnalyticsResource analytics() { return analytics; } diff --git a/src/main/java/com/fopost/sdk/model/Platforms.java b/src/main/java/com/fopost/sdk/model/Platforms.java index 2445935..c1c88bc 100644 --- a/src/main/java/com/fopost/sdk/model/Platforms.java +++ b/src/main/java/com/fopost/sdk/model/Platforms.java @@ -44,9 +44,11 @@ private Platforms() {} public static final String WHOP = "whop"; public static final String SKOOL = "skool"; + public static final String WHATSAPP = "whatsapp"; + public static final List ALL = List.of( TWITTER, LINKEDIN, FACEBOOK, INSTAGRAM, INSTAGRAM_BUSINESS, TELEGRAM, TWITCH, DISCORD, SLACK, REDDIT, PINTEREST, TUMBLR, DRIBBBLE, MEWE, TIKTOK, YOUTUBE, BLUESKY, THREADS, MASTODON, LEMMY, DEVTO, HASHNODE, MEDIUM, SUBSTACK, GOOGLE_BUSINESS, KICK, LISTMONK, - WORDPRESS, NOSTR, WHOP, SKOOL); + WORDPRESS, NOSTR, WHOP, SKOOL, WHATSAPP); } diff --git a/src/main/java/com/fopost/sdk/model/WhatsappBlockResult.java b/src/main/java/com/fopost/sdk/model/WhatsappBlockResult.java new file mode 100644 index 0000000..e25c7a7 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappBlockResult.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** What the platform took and what it refused. */ +public record WhatsappBlockResult(List blocked, List unblocked, List failed) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappCommerceSettings.java b/src/main/java/com/fopost/sdk/model/WhatsappCommerceSettings.java new file mode 100644 index 0000000..6e36408 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappCommerceSettings.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** Whether the cart and catalog show on the number, and which catalog is linked. */ +public record WhatsappCommerceSettings(Boolean cartEnabled, Boolean catalogVisible, String catalogId) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappEncryptionKeyStatus.java b/src/main/java/com/fopost/sdk/model/WhatsappEncryptionKeyStatus.java new file mode 100644 index 0000000..665a328 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappEncryptionKeyStatus.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** Whether a business public key is registered. The key itself never comes back. */ +public record WhatsappEncryptionKeyStatus(boolean hasKey, String signatureStatus) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappFlow.java b/src/main/java/com/fopost/sdk/model/WhatsappFlow.java new file mode 100644 index 0000000..e98f8ec --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappFlow.java @@ -0,0 +1,16 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** An in-chat form. The platform validates it and owns its status. */ +public record WhatsappFlow( + String id, + String name, + String status, + List categories, + List validationErrors, + String endpointUri, + String jsonVersion, + String previewUrl, + Instant previewExpiresAt) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappFlowJsonResult.java b/src/main/java/com/fopost/sdk/model/WhatsappFlowJsonResult.java new file mode 100644 index 0000000..5fc0076 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappFlowJsonResult.java @@ -0,0 +1,9 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * The platform's verdict on an uploaded flow definition. It answers with the + * errors rather than refusing the upload, so they arrive as data. + */ +public record WhatsappFlowJsonResult(boolean success, List validationErrors) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappFlowResponse.java b/src/main/java/com/fopost/sdk/model/WhatsappFlowResponse.java new file mode 100644 index 0000000..89df9f8 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappFlowResponse.java @@ -0,0 +1,12 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.Map; + +/** What one person submitted through a flow. */ +public record WhatsappFlowResponse( + String messageId, + String waId, + String flowToken, + Map answers, + Instant respondedAt) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappFlowValidationError.java b/src/main/java/com/fopost/sdk/model/WhatsappFlowValidationError.java new file mode 100644 index 0000000..f71de69 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappFlowValidationError.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** One problem the platform found in a flow definition. */ +public record WhatsappFlowValidationError(String error, String message) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappGroup.java b/src/main/java/com/fopost/sdk/model/WhatsappGroup.java new file mode 100644 index 0000000..8a08f50 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappGroup.java @@ -0,0 +1,15 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * A group on the business number. Participation is invite-only: no endpoint adds + * someone, so {@code inviteLink} is how they join. + */ +public record WhatsappGroup( + String id, + String subject, + String description, + Integer participantCount, + String inviteLink, + Instant createdAt) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappProfile.java b/src/main/java/com/fopost/sdk/model/WhatsappProfile.java new file mode 100644 index 0000000..44aa42c --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappProfile.java @@ -0,0 +1,21 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * The business profile on a WhatsApp number, plus how the platform rates it. + * {@code displayNameStatus} is the platform's review state for the display name. + */ +public record WhatsappProfile( + String about, + String address, + String description, + String email, + String vertical, + List websites, + String profilePictureUrl, + String displayName, + String displayNameStatus, + String username, + String qualityRating, + String messagingLimitTier) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappSandboxSession.java b/src/main/java/com/fopost/sdk/model/WhatsappSandboxSession.java new file mode 100644 index 0000000..24d42fd --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappSandboxSession.java @@ -0,0 +1,15 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * A sandbox invitation. Only the last four digits of the tester's number + * travel; the number itself is never stored. + */ +public record WhatsappSandboxSession( + String id, + String status, + String phoneNumberLast4, + Instant invitedAt, + Instant activatedAt, + Instant expiresAt) {} diff --git a/src/main/java/com/fopost/sdk/model/WhatsappTemplate.java b/src/main/java/com/fopost/sdk/model/WhatsappTemplate.java new file mode 100644 index 0000000..be985a7 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WhatsappTemplate.java @@ -0,0 +1,19 @@ +package com.fopost.sdk.model; + +import java.util.List; +import java.util.Map; + +/** + * A message template. {@code status} is the review outcome the platform + * assigned, passed through unchanged: nothing marks a template approved but the + * platform. + */ +public record WhatsappTemplate( + String id, + String name, + String language, + String category, + String status, + String rejectedReason, + List> components, + String qualityScore) {} diff --git a/src/main/java/com/fopost/sdk/resource/WhatsappResource.java b/src/main/java/com/fopost/sdk/resource/WhatsappResource.java new file mode 100644 index 0000000..43899ac --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/WhatsappResource.java @@ -0,0 +1,429 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.WhatsappBlockResult; +import com.fopost.sdk.model.WhatsappCommerceSettings; +import com.fopost.sdk.model.WhatsappEncryptionKeyStatus; +import com.fopost.sdk.model.WhatsappFlow; +import com.fopost.sdk.model.WhatsappFlowJsonResult; +import com.fopost.sdk.model.WhatsappFlowResponse; +import com.fopost.sdk.model.WhatsappGroup; +import com.fopost.sdk.model.WhatsappProfile; +import com.fopost.sdk.model.WhatsappSandboxSession; +import com.fopost.sdk.model.WhatsappTemplate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A WhatsApp Business connection: a number the customer already owns. + * + *

The platform owns templates, flows, the business profile and the commerce + * settings, so every method here is a live read or write against the customer's + * own WhatsApp Business Account. Nothing is cached, and all of it answers 503 + * until WhatsApp is set up on the deployment. Every method needs the + * {@code accounts} scope, except the sandbox, which sends a template and needs + * {@code publish}. + */ +public final class WhatsappResource { + + private final ApiClient http; + + public WhatsappResource(ApiClient http) { + this.http = http; + } + + private String base(String accountId) { + return "/v1/accounts/" + accountId + "/whatsapp"; + } + + // ─── Profile ────────────────────────────────────────────────────────────── + + public WhatsappProfile getProfile(String accountId) { + return http.convert( + ApiClient.unwrap(http.get(base(accountId) + "/profile", null)), WhatsappProfile.class); + } + + /** A partial update: keys left out of {@code fields} keep their value. */ + public WhatsappProfile updateProfile(String accountId, Map fields) { + return http.convert( + ApiClient.unwrap(http.request("PATCH", base(accountId) + "/profile", fields, null)), + WhatsappProfile.class); + } + + /** A review, not a write: the number keeps its old name until it passes. */ + public void requestDisplayName(String accountId, String displayName) { + Map body = new LinkedHashMap<>(); + body.put("display_name", displayName); + http.post(base(accountId) + "/profile/display-name", body); + } + + public WhatsappProfile setUsername(String accountId, String username) { + Map body = new LinkedHashMap<>(); + body.put("username", username); + return http.convert( + ApiClient.unwrap(http.put(base(accountId) + "/profile/username", body)), + WhatsappProfile.class); + } + + // ─── Templates ──────────────────────────────────────────────────────────── + + public List listTemplates(String accountId) { + return listTemplates(accountId, null); + } + + public List listTemplates(String accountId, String after) { + Map query = new LinkedHashMap<>(); + if (after != null) { + query.put("after", after); + } + return http.convertList( + ApiClient.unwrap(http.get(base(accountId) + "/templates", query)), WhatsappTemplate.class); + } + + /** The pre-written templates the platform offers, for adapting. */ + public List> listTemplateLibrary(String accountId, String search) { + Map query = new LinkedHashMap<>(); + if (search != null) { + query.put("search", search); + } + return http.convertList( + ApiClient.unwrap(http.get(base(accountId) + "/templates/library", query)), Map.class) + .stream() + .map(entry -> (Map) entry) + .toList(); + } + + public WhatsappTemplate getTemplate(String accountId, String templateId) { + return http.convert( + ApiClient.unwrap(http.get(base(accountId) + "/templates/" + templateId, null)), + WhatsappTemplate.class); + } + + /** + * Files a template for review. The result carries the status the platform + * assigned, which is {@code PENDING} on a normal submission. + */ + public WhatsappTemplate createTemplate( + String accountId, + String name, + String language, + String category, + List> components) { + return createTemplate(accountId, name, language, category, components, null); + } + + public WhatsappTemplate createTemplate( + String accountId, + String name, + String language, + String category, + List> components, + Boolean allowCategoryChange) { + Map body = new LinkedHashMap<>(); + body.put("name", name); + body.put("language", language); + body.put("category", category); + body.put("components", components); + if (allowCategoryChange != null) { + body.put("allow_category_change", allowCategoryChange); + } + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/templates", body)), WhatsappTemplate.class); + } + + /** Creates a template from one of the platform's library entries. */ + public WhatsappTemplate importTemplate( + String accountId, + String libraryTemplateName, + String name, + String language, + String category, + List> libraryTemplateButtonInputs) { + Map body = new LinkedHashMap<>(); + body.put("library_template_name", libraryTemplateName); + body.put("name", name); + body.put("language", language); + body.put("category", category); + if (libraryTemplateButtonInputs != null) { + body.put("library_template_button_inputs", libraryTemplateButtonInputs); + } + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/templates/import", body)), + WhatsappTemplate.class); + } + + /** Edits a template. The name cannot change; create a new one instead. */ + public WhatsappTemplate updateTemplate( + String accountId, String templateId, String category, List> components) { + Map body = new LinkedHashMap<>(); + if (category != null) { + body.put("category", category); + } + if (components != null) { + body.put("components", components); + } + return http.convert( + ApiClient.unwrap( + http.request("PATCH", base(accountId) + "/templates/" + templateId, body, null)), + WhatsappTemplate.class); + } + + /** The name is required: it is what the platform deletes by. */ + public void deleteTemplate(String accountId, String templateId, String name) { + Map query = new LinkedHashMap<>(); + query.put("name", name); + http.request("DELETE", base(accountId) + "/templates/" + templateId, null, query); + } + + // ─── Groups ─────────────────────────────────────────────────────────────── + + public List listGroups(String accountId) { + return http.convertList( + ApiClient.unwrap(http.get(base(accountId) + "/groups", null)), WhatsappGroup.class); + } + + /** Participation is invite-only: send the invite link, there is no add. */ + public WhatsappGroup createGroup(String accountId, String subject, String description) { + Map body = new LinkedHashMap<>(); + body.put("subject", subject); + if (description != null) { + body.put("description", description); + } + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/groups", body)), WhatsappGroup.class); + } + + public WhatsappGroup getGroup(String accountId, String groupId) { + return http.convert( + ApiClient.unwrap(http.get(base(accountId) + "/groups/" + groupId, null)), + WhatsappGroup.class); + } + + public WhatsappGroup updateGroup( + String accountId, String groupId, String subject, String description) { + Map body = new LinkedHashMap<>(); + if (subject != null) { + body.put("subject", subject); + } + if (description != null) { + body.put("description", description); + } + return http.convert( + ApiClient.unwrap(http.request("PATCH", base(accountId) + "/groups/" + groupId, body, null)), + WhatsappGroup.class); + } + + public void deleteGroup(String accountId, String groupId) { + http.delete(base(accountId) + "/groups/" + groupId); + } + + /** The link someone joins the group with. */ + public String getGroupInviteLink(String accountId, String groupId) { + return inviteLinkOf( + http.get(base(accountId) + "/groups/" + groupId + "/invite-link", null)); + } + + /** Issues a new link and invalidates the old one. */ + public String resetGroupInviteLink(String accountId, String groupId) { + return inviteLinkOf(http.post(base(accountId) + "/groups/" + groupId + "/invite-link", null)); + } + + private String inviteLinkOf(com.fasterxml.jackson.databind.JsonNode body) { + var link = ApiClient.unwrap(body).get("inviteLink"); + return link == null || link.isNull() ? null : link.asText(); + } + + public void removeGroupParticipants(String accountId, String groupId, List users) { + Map body = new LinkedHashMap<>(); + body.put("users", users); + http.request("DELETE", base(accountId) + "/groups/" + groupId + "/participants", body, null); + } + + // ─── Blocking ───────────────────────────────────────────────────────────── + + public List listBlocked(String accountId, String after) { + Map query = new LinkedHashMap<>(); + if (after != null) { + query.put("after", after); + } + return http.convertList(ApiClient.unwrap(http.get(base(accountId) + "/block", query)), String.class); + } + + public WhatsappBlockResult blockUsers(String accountId, List users) { + Map body = new LinkedHashMap<>(); + body.put("users", users); + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/block", body)), WhatsappBlockResult.class); + } + + public WhatsappBlockResult unblockUsers(String accountId, List users) { + Map body = new LinkedHashMap<>(); + body.put("users", users); + return http.convert( + ApiClient.unwrap(http.request("DELETE", base(accountId) + "/block", body, null)), + WhatsappBlockResult.class); + } + + // ─── Commerce ───────────────────────────────────────────────────────────── + + public WhatsappCommerceSettings getCommerceSettings(String accountId) { + return http.convert( + ApiClient.unwrap(http.get(base(accountId) + "/commerce", null)), + WhatsappCommerceSettings.class); + } + + public WhatsappCommerceSettings updateCommerceSettings( + String accountId, Boolean cartEnabled, Boolean catalogVisible) { + Map body = new LinkedHashMap<>(); + if (cartEnabled != null) { + body.put("is_cart_enabled", cartEnabled); + } + if (catalogVisible != null) { + body.put("is_catalog_visible", catalogVisible); + } + return http.convert( + ApiClient.unwrap(http.request("PATCH", base(accountId) + "/commerce", body, null)), + WhatsappCommerceSettings.class); + } + + /** Points the number at a catalog the customer already owns. */ + public WhatsappCommerceSettings linkCatalog(String accountId, String catalogId) { + Map body = new LinkedHashMap<>(); + body.put("catalog_id", catalogId); + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/commerce/catalog", body)), + WhatsappCommerceSettings.class); + } + + // ─── Flows ──────────────────────────────────────────────────────────────── + + public List listFlows(String accountId) { + return http.convertList( + ApiClient.unwrap(http.get(base(accountId) + "/flows", null)), WhatsappFlow.class); + } + + public WhatsappFlow getFlow(String accountId, String flowId) { + return http.convert( + ApiClient.unwrap(http.get(base(accountId) + "/flows/" + flowId, null)), WhatsappFlow.class); + } + + /** Creates a draft flow; its screens are uploaded separately. */ + public WhatsappFlow createFlow(String accountId, String name, List categories) { + return createFlow(accountId, name, categories, null, null); + } + + public WhatsappFlow createFlow( + String accountId, String name, List categories, String endpointUri, String cloneFlowId) { + Map body = new LinkedHashMap<>(); + body.put("name", name); + body.put("categories", categories); + if (endpointUri != null) { + body.put("endpoint_uri", endpointUri); + } + if (cloneFlowId != null) { + body.put("clone_flow_id", cloneFlowId); + } + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/flows", body)), WhatsappFlow.class); + } + + public WhatsappFlow updateFlow( + String accountId, String flowId, String name, List categories, String endpointUri) { + Map body = new LinkedHashMap<>(); + if (name != null) { + body.put("name", name); + } + if (categories != null) { + body.put("categories", categories); + } + if (endpointUri != null) { + body.put("endpoint_uri", endpointUri); + } + return http.convert( + ApiClient.unwrap(http.request("PATCH", base(accountId) + "/flows/" + flowId, body, null)), + WhatsappFlow.class); + } + + /** Drafts only; a published flow is deprecated instead. */ + public void deleteFlow(String accountId, String flowId) { + http.delete(base(accountId) + "/flows/" + flowId); + } + + /** + * Replaces the flow's screens. The platform answers with its validation + * errors rather than refusing, so they come back as data. + */ + public WhatsappFlowJsonResult uploadFlowJson( + String accountId, String flowId, Map flowJson) { + Map body = new LinkedHashMap<>(); + body.put("flow_json", flowJson); + return http.convert( + ApiClient.unwrap(http.put(base(accountId) + "/flows/" + flowId + "/json", body)), + WhatsappFlowJsonResult.class); + } + + public WhatsappFlow publishFlow(String accountId, String flowId) { + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/flows/" + flowId + "/publish", null)), + WhatsappFlow.class); + } + + public WhatsappFlow deprecateFlow(String accountId, String flowId) { + return http.convert( + ApiClient.unwrap(http.post(base(accountId) + "/flows/" + flowId + "/deprecate", null)), + WhatsappFlow.class); + } + + public List listFlowResponses(String accountId) { + return http.convertList( + ApiClient.unwrap(http.get(base(accountId) + "/flows/responses", null)), + WhatsappFlowResponse.class); + } + + public WhatsappEncryptionKeyStatus getEncryptionKeyStatus(String accountId) { + return http.convert( + ApiClient.unwrap(http.get(base(accountId) + "/flows/encryption-key", null)), + WhatsappEncryptionKeyStatus.class); + } + + /** + * Registers the public half of the key the platform encrypts a flow + * endpoint's payloads with. The private half stays with the customer. + */ + public WhatsappEncryptionKeyStatus setEncryptionKey(String accountId, String businessPublicKey) { + Map body = new LinkedHashMap<>(); + body.put("business_public_key", businessPublicKey); + return http.convert( + ApiClient.unwrap(http.put(base(accountId) + "/flows/encryption-key", body)), + WhatsappEncryptionKeyStatus.class); + } + + // ─── Account state and sandbox ──────────────────────────────────────────── + + /** The account review state and the number's quality and limit tier. */ + public Map getAccountEvents(String accountId) { + return http.convert(ApiClient.unwrap(http.get(base(accountId) + "/events", null)), Map.class); + } + + public List listSandboxSessions(String workspaceId) { + Map query = new LinkedHashMap<>(); + query.put("workspaceId", workspaceId); + return http.convertList( + ApiClient.unwrap(http.get("/v1/whatsapp/sandbox/sessions", query)), + WhatsappSandboxSession.class); + } + + /** + * Invites one tester to the platform-owned test number. Inviting sends a + * template, so it needs the {@code publish} scope. + */ + public WhatsappSandboxSession createSandboxSession(String workspaceId, String phoneNumber) { + Map body = new LinkedHashMap<>(); + body.put("workspaceId", workspaceId); + body.put("phoneNumber", phoneNumber); + return http.convert( + ApiClient.unwrap(http.post("/v1/whatsapp/sandbox/sessions", body)), + WhatsappSandboxSession.class); + } +} diff --git a/src/test/java/com/fopost/sdk/WhatsappTest.java b/src/test/java/com/fopost/sdk/WhatsappTest.java new file mode 100644 index 0000000..44875da --- /dev/null +++ b/src/test/java/com/fopost/sdk/WhatsappTest.java @@ -0,0 +1,70 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.Platforms; +import com.fopost.sdk.model.WhatsappSandboxSession; +import com.fopost.sdk.model.WhatsappTemplate; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class WhatsappTest { + + @Test + void whatsappIsOnThePlatformList() { + assertTrue(Platforms.ALL.contains(Platforms.WHATSAPP)); + assertEquals("whatsapp", Platforms.WHATSAPP); + } + + @Test + void createTemplateReturnsTheReviewStatusThePlatformGaveIt() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"id":"tpl-1","name":"order_shipped","language":"en_US","category":"UTILITY", + "status":"PENDING","rejectedReason":null,"components":[],"qualityScore":null}}"""); + + WhatsappTemplate template = TestSupport.client(transport) + .whatsapp() + .createTemplate( + "a1", + "order_shipped", + "en_US", + "UTILITY", + List.of(Map.of("type", "BODY", "text", "On its way."))); + + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/accounts/a1/whatsapp/templates", transport.last().url()); + // Nothing marks a template approved but the platform. + assertEquals("PENDING", template.status()); + assertEquals("order_shipped", template.name()); + } + + @Test + void deleteTemplateNamesItInTheQuery() { + FakeTransport transport = new FakeTransport().enqueue(200, """ + {"data":{"deleted":true}}"""); + + TestSupport.client(transport).whatsapp().deleteTemplate("a1", "tpl-1", "order_shipped"); + + assertEquals("DELETE", transport.last().method()); + assertTrue(transport.last().url().endsWith("/whatsapp/templates/tpl-1?name=order_shipped")); + } + + @Test + void sandboxSessionCarriesOnlyTheLastFourDigits() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"id":"ses-1","status":"invited","phoneNumberLast4":"4567", + "invitedAt":"2026-09-20T10:00:00Z","activatedAt":null, + "expiresAt":"2026-09-21T10:00:00Z"}}"""); + + WhatsappSandboxSession session = + TestSupport.client(transport).whatsapp().createSandboxSession("ws", "+15551234567"); + + assertEquals("https://api.fopost.test/v1/whatsapp/sandbox/sessions", transport.last().url()); + assertEquals("4567", session.phoneNumberLast4()); + assertEquals("invited", session.status()); + } +}