diff --git a/CHANGELOG.md b/CHANGELOG.md index 3851a85..561f924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ 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`. - `blog()`: articles for a website. `getSettings`, `updateSettings`, `rotateWebsiteSecret` and `testWebsite`; `listKeywords`, `planKeywords`, `addKeyword`, `updateKeyword` and `deleteKeyword`; `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, diff --git a/README.md b/README.md index 3dde80b..b7715f0 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` | | `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`, `goals`, `catalogs`, `createCatalog`, `catalog`, `updateCatalog`, `deleteCatalog`, `catalogProducts`, `writeCatalogProducts`, `productFeeds`, `createProductFeed`, `deleteProductFeed`, `feedUploads`, `startFeedUpload`, `productSets`, `createProductSet`, `updateProductSet`, `deleteProductSet`, `reachFrequency`, `createReachFrequency`, `reachFrequencyPrediction`, `reserveReachFrequency`, `cancelReachFrequency`, `library`, `partnershipCreators`, `requestPartnership`, `revokePartnership`, `accountActivity`, `labels`, `createLabel`, `updateLabel`, `deleteLabel`, `applyLabel`, `studies`, `createStudy`, `study`, `deleteStudy`, `iosCampaignLimits`, `highDemandPeriods`, `createHighDemandPeriod`, `deleteHighDemandPeriod`, `valueRuleSets`, `createValueRuleSet`, `deleteValueRuleSet` | | `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` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `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`, `addAudienceCompanies`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `bidPricing`, `supplyForecast`, `conversionRules`, `createConversionRule`, `conversionRule`, `updateConversionRule`, `deleteConversionRule`, `attachConversionRule`, `detachConversionRule`, `conversionMetrics`, `sendConversionEvents` | | `googleAds()` | `recommendations`, `optimizationScore`, `applyRecommendations`, `dismissRecommendations`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, `keywordIdeas`, `keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, `setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, `attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, `assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, `localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, `uploadConversionAdjustments`, `query` | | `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` | diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index de3ccd4..b99d595 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -26,6 +26,7 @@ import com.fopost.sdk.resource.SequencesResource; 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; @@ -80,6 +81,7 @@ public final class FoPost { private final SequencesResource sequences; private final ActivityResource activity; private final GoogleBusinessResource googleBusiness; + private final WhatsappResource whatsapp; private final InboxResource inbox; private final AdsResource ads; private final GoogleAdsResource googleAds; @@ -101,6 +103,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.contacts = new ContactsResource(http); this.broadcasts = new BroadcastsResource(http); @@ -153,6 +156,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 90d8bb7..49274cf 100644 --- a/src/main/java/com/fopost/sdk/model/Platforms.java +++ b/src/main/java/com/fopost/sdk/model/Platforms.java @@ -45,9 +45,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, SNAPCHAT, 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()); + } +}