diff --git a/CHANGELOG.md b/CHANGELOG.md index 0258e56..e2fa27e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ All notable changes to this project are documented here. The format follows ### Added +- `broadcasts()`: one message into every conversation the workspace already has with a + segment of its contacts. `list`, `get`, `create`, `update`, `delete`, `send`, `cancel` + and `recipients`. Reading needs the `inbox` scope; `send` and `cancel` also need `publish`. +- `sequences()`: a series of messages on a delay. `list`, `get`, `create`, `update`, + `delete`, `enroll`, `unenroll` and `enrollments`. `enroll` and `unenroll` need `publish`. +- Both honour each network's messaging window server-side. Messenger and Instagram take a + business-initiated message only within 24 hours of the contact's last one, so recipients + outside it come back `skipped` with `skip_reason` `window_closed` and nothing is + attempted — the number sent is often lower than the audience. + +- `contacts()`: the people behind the inbox. `list`, `get`, `create`, `update`, `delete`, + `conversations` (the threads one person appears in), `importCsv`, and + `listFields`/`createField`/`updateField`/`updateFieldOptions`/`deleteField` for the + custom columns a workspace keeps. All need the `inbox` scope. +- `contacts().conversationAnalytics` reads `/v1/analytics/inbox/conversations`: volume and + median reply time per thread. Needs the `analytics` scope. - Meta messaging settings on `accounts()`: `getIceBreakers`, `setIceBreakers` and `deleteIceBreakers` (Facebook Pages and Instagram), plus `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting` and diff --git a/CLAUDE.md b/CLAUDE.md index b44141a..087b287 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,8 @@ src/main/java/com/fopost/sdk/ resource/ PostsResource, AccountsResource (+ .communities()), WorkspacesResource, LabelsResource, WebhooksResource, AnalyticsResource, AutomationsResource, MediaResource, AiResource, CommunitiesResource, - InboxResource, AdsResource, ValidateResource + InboxResource, AdsResource, ValidateResource, + BroadcastsResource, SequencesResource ``` **Request flow.** `client.posts().create(params)` → `PostsResource` builds the body and calls @@ -80,10 +81,24 @@ calls `ApiClient.unwrap(...)` and `convert(...)`/`convertList(...)` into a recor - `FoPost` instances are immutable and safe to share across threads. **Resources wired today:** `posts`, `accounts` (with `accounts().communities()`), `workspaces`, -`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `ads`, `validate`. This is +`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `validate`. This is the most complete of the FoPost SDKs — do not narrow it. `FoPost.request(...)` is the escape hatch for anything unwrapped. +- `contacts` (scope `inbox`) covers `/v1/contacts/*`: the CRUD, `import`, + `{id}/conversations` and the `/v1/contacts/fields` family. Its list envelope is + `{data, pagination}`, so it decodes into `ContactPage` rather than `Page`. + `createField` puts the workspace on the query string because the handler reads it there. + `ContactParams.Update` serializes its `fields` through an `ObjectNode`: the mapper is + configured `NON_NULL`, so a plain map would drop the null that clears a field. + `conversationAnalytics` reaches `/v1/analytics/inbox/conversations` and needs `analytics`. +- `broadcasts` and `sequences` (scope `inbox`) cover `/v1/broadcasts/*` and `/v1/sequences/*`. + `send`, `cancel`, `enroll` and `unenroll` also need `publish`, because they reach a platform. + Both list envelopes are `{data, pagination}` like contacts, so both decode into their own + page records. A recipient's `skipReason` is the messaging window's record: `window_closed` + means the network's 24-hour window had shut and nothing was attempted, so a sent count lower + than the audience is correct rather than a failure. Say that in the javadoc of anything new + that sends. - `inbox` (scope `inbox`) covers `/v1/inbox/*` except `/v1/inbox/chat/*` (browser-encrypted X Chat) and `/v1/inbox/{id}/attachments/{index}` (a binary stream; this SDK has no download pattern). Its lists carry `meta: { page, perPage, total }`, mapped by `model/InboxPage` + diff --git a/README.md b/README.md index 799b5fc..43f4649 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,9 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `media()` | `list`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | | `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`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` | -| `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` | +| `contacts()` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `importCsv`, `listFields`, `createField`, `updateField`, `updateFieldOptions`, `deleteField`, `conversationAnalytics` | +| `broadcasts()` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` | +| `sequences()` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | | `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` | | `validate()` | `post`, `length`, `media` | @@ -123,6 +125,41 @@ the decoded body: JsonNode body = client.request("GET", "/v1/analytics/overview", null, Map.of("days", 30)); ``` +## Broadcasts and sequences + +A broadcast is one message into every conversation you already have with a segment of your contacts; a sequence is a series of them on a delay. Neither opens a cold DM. + +Nothing is sent into a closed messaging window: Messenger and Instagram take a business-initiated message only within 24 hours of the contact's last one, so recipients outside it come back skipped with `window_closed` rather than attempted. Telegram, Slack, Bluesky and Reddit have no window. The number sent is therefore often lower than the audience, and that is correct rather than a failure. + +Reading needs the `inbox` scope; `send`, `cancel`, `enroll` and `unenroll` also need `publish`. + +```java +Broadcast broadcast = client.broadcasts().create( + BroadcastParams.Create.of(workspaceId, accountId, "September check-in", + "New colours just landed. Want a look?") + .audience(AudienceFilter.all().platforms("instagram"))); + +// recipients() on the result is how many contacts matched, not how many will be messaged. +BroadcastSent sent = client.broadcasts().send(broadcast.id()); + +// Who was skipped, and why. +for (BroadcastRecipient r : client.broadcasts() + .recipients(broadcast.id(), BroadcastParams.Recipients.create().status("skipped"))) { + System.out.println(r.displayName() + ": " + r.skipReason()); +} + +Sequence sequence = client.sequences().create( + BroadcastParams.CreateSequence.of(workspaceId, accountId, "Welcome", List.of( + SequenceStep.of(0, "Thanks for the follow — anything I can help with?"), + SequenceStep.of(48, "Here is what people usually ask us first.")))); + +// By id, or by the same audience filter a broadcast takes. +client.sequences().enroll(sequence.id(), BroadcastParams.Enroll.contacts(List.of(contactId))); + +// Nothing further fires for them. +client.sequences().unenroll(sequence.id(), List.of(contactId)); +``` + ## Media Upload once, then attach the returned file to a content block: @@ -206,6 +243,45 @@ The actions that act on the platform as the account also need the `publish` scop `InboxReplyParams` carrying `mediaIds` or `quickReplies`, and deleting our own reply. Each works only where the item's matching `can*` flag is true. +## Contacts + +The people behind the inbox: one person however many handles they write from. An inbound item files its author, a reply files whoever you answered, and both fold into whatever is already on file. Needs the `inbox` scope. + +```java +ContactPage page = client.contacts().list( + ContactParams.Filter.create().workspace(workspaceId).search("ada")); +for (Contact contact : page) { + System.out.println(contact.displayName() + " — " + contact.channels().size() + " handles"); +} + +// Folds into whoever already holds the first channel, so this cannot duplicate someone. +Contact contact = client.contacts().create( + ContactParams.Create.of(workspaceId, List.of(ContactChannel.of("x", "ada_writes"))) + .displayName("Ada Okafor") + .fields(Map.of("plan_tier", "Pro"))); + +client.contacts().update(contact.id(), ContactParams.Update.create().clearField("region")); +client.contacts().delete(contact.id()); // the messages stay in the inbox + +// The threads this person appears in, newest first. +for (ContactConversation thread : client.contacts().conversations(contact.id())) { + System.out.println(thread.platform() + " " + thread.messages() + " messages"); +} + +// platform and handle are required columns; any other column is a custom field key. +ContactImportResult result = client.contacts().importCsv(workspaceId, "platform,handle\nx,ada_writes"); +System.out.println(result.created() + " created, " + result.merged() + " merged"); + +// The columns your workspace keeps. +ContactField field = client.contacts() + .createField(workspaceId, "plan_tier", "Plan Tier", "select", List.of("Free", "Pro")); +client.contacts().deleteField(field.id()); // removes every answer to it + +// Volume and median reply time per thread. Needs the `analytics` scope. +ConversationAnalytics report = client.contacts() + .conversationAnalytics(ContactParams.Conversations.create().days(30).sort("slowest")); +``` + ## Ads Boosts, standalone ads, audiences and lead forms on the connected ad accounts. diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index c640497..8c85d64 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -12,11 +12,14 @@ import com.fopost.sdk.resource.AiResource; import com.fopost.sdk.resource.AnalyticsResource; import com.fopost.sdk.resource.AutomationsResource; +import com.fopost.sdk.resource.BroadcastsResource; +import com.fopost.sdk.resource.ContactsResource; import com.fopost.sdk.resource.InboxResource; import com.fopost.sdk.resource.KnowledgeResource; import com.fopost.sdk.resource.LabelsResource; import com.fopost.sdk.resource.MediaResource; import com.fopost.sdk.resource.PostsResource; +import com.fopost.sdk.resource.SequencesResource; import com.fopost.sdk.resource.ValidateResource; import com.fopost.sdk.resource.WebhooksResource; import com.fopost.sdk.resource.WorkspacesResource; @@ -68,6 +71,9 @@ public final class FoPost { private final AutomationsResource automations; private final MediaResource media; private final AiResource ai; + private final ContactsResource contacts; + private final BroadcastsResource broadcasts; + private final SequencesResource sequences; private final InboxResource inbox; private final AdsResource ads; private final ValidateResource validate; @@ -86,6 +92,9 @@ private FoPost(ApiClient http) { this.media = new MediaResource(http); this.ai = new AiResource(http); this.inbox = new InboxResource(http); + this.contacts = new ContactsResource(http); + this.broadcasts = new BroadcastsResource(http); + this.sequences = new SequencesResource(http); this.ads = new AdsResource(http); this.validate = new ValidateResource(http); } @@ -152,6 +161,24 @@ public InboxResource inbox() { return inbox; } + /** The people behind the inbox, and the fields kept about them. */ + public ContactsResource contacts() { + return contacts; + } + + /** + * One message into every conversation the workspace already has with a segment of its + * contacts. + */ + public BroadcastsResource broadcasts() { + return broadcasts; + } + + /** A series of messages on a delay, walked per enrolled contact. */ + public SequencesResource sequences() { + return sequences; + } + public AdsResource ads() { return ads; } diff --git a/src/main/java/com/fopost/sdk/model/AudienceField.java b/src/main/java/com/fopost/sdk/model/AudienceField.java new file mode 100644 index 0000000..c88dd83 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AudienceField.java @@ -0,0 +1,33 @@ +package com.fopost.sdk.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * One custom-field clause in an audience filter. + * + *

{@code op} is {@code is}, {@code is_not}, {@code contains}, {@code is_set} or + * {@code is_not_set}; null means {@code is}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AudienceField(String key, String op, String value) { + + public static AudienceField is(String key, String value) { + return new AudienceField(key, "is", value); + } + + public static AudienceField isNot(String key, String value) { + return new AudienceField(key, "is_not", value); + } + + public static AudienceField contains(String key, String value) { + return new AudienceField(key, "contains", value); + } + + public static AudienceField isSet(String key) { + return new AudienceField(key, "is_set", null); + } + + public static AudienceField isNotSet(String key) { + return new AudienceField(key, "is_not_set", null); + } +} diff --git a/src/main/java/com/fopost/sdk/model/AudienceFilter.java b/src/main/java/com/fopost/sdk/model/AudienceFilter.java new file mode 100644 index 0000000..cdd1743 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AudienceFilter.java @@ -0,0 +1,37 @@ +package com.fopost.sdk.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** + * Who a broadcast or an enrollment resolves to, expressed over contacts. + * + *

Every clause narrows: a contact has to match all of them. A null clause is not sent. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AudienceFilter( + List platforms, List labelIds, String source, List fields) { + + /** Everyone in the workspace. */ + public static AudienceFilter all() { + return new AudienceFilter(null, null, null, null); + } + + /** Contacts with a handle on at least one of these networks. */ + public AudienceFilter platforms(String... platforms) { + return new AudienceFilter(List.of(platforms), labelIds, source, fields); + } + + public AudienceFilter labelIds(String... ids) { + return new AudienceFilter(platforms, List.of(ids), source, fields); + } + + /** {@code inbox}, {@code radar} or {@code import}. */ + public AudienceFilter source(String source) { + return new AudienceFilter(platforms, labelIds, source, fields); + } + + public AudienceFilter fields(List fields) { + return new AudienceFilter(platforms, labelIds, source, fields); + } +} diff --git a/src/main/java/com/fopost/sdk/model/Broadcast.java b/src/main/java/com/fopost/sdk/model/Broadcast.java new file mode 100644 index 0000000..787be60 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/Broadcast.java @@ -0,0 +1,23 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One message, sent into conversations the workspace already has. + * + *

{@code name} is internal only and is never sent to anyone. {@code status} is + * {@code draft}, {@code scheduled}, {@code sending}, {@code sent} or {@code cancelled}. + * {@code workspaceId} is set only on a listing that spans workspaces. + */ +public record Broadcast( + String id, + String name, + String text, + String accountId, + AudienceFilter audience, + String status, + Instant scheduledAt, + Instant sentAt, + Instant createdAt, + BroadcastCounts counts, + String workspaceId) {} diff --git a/src/main/java/com/fopost/sdk/model/BroadcastCounts.java b/src/main/java/com/fopost/sdk/model/BroadcastCounts.java new file mode 100644 index 0000000..4a3c792 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BroadcastCounts.java @@ -0,0 +1,8 @@ +package com.fopost.sdk.model; + +/** + * What became of a broadcast's recipients, by status. + * + *

{@code skipped} is usually the messaging window doing its job. + */ +public record BroadcastCounts(int total, int sent, int skipped, int failed, int pending) {} diff --git a/src/main/java/com/fopost/sdk/model/BroadcastPage.java b/src/main/java/com/fopost/sdk/model/BroadcastPage.java new file mode 100644 index 0000000..53ba346 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BroadcastPage.java @@ -0,0 +1,26 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** One page of broadcasts: its rows plus the pagination block. Iterates over its rows. */ +public record BroadcastPage(List data, BroadcastPageMeta pagination) + implements Iterable { + + public BroadcastPage { + data = data == null ? List.of() : List.copyOf(data); + } + + @Override + public Iterator iterator() { + return data.iterator(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } +} diff --git a/src/main/java/com/fopost/sdk/model/BroadcastPageMeta.java b/src/main/java/com/fopost/sdk/model/BroadcastPageMeta.java new file mode 100644 index 0000000..97e91d4 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BroadcastPageMeta.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** The pagination block a broadcast, recipient, sequence or enrollment listing carries. */ +public record BroadcastPageMeta(int page, int perPage, long total) {} diff --git a/src/main/java/com/fopost/sdk/model/BroadcastRecipient.java b/src/main/java/com/fopost/sdk/model/BroadcastRecipient.java new file mode 100644 index 0000000..dbbc3b0 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BroadcastRecipient.java @@ -0,0 +1,19 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One contact on one broadcast, and what became of their message. + * + *

{@code status} is {@code pending}, {@code sent}, {@code skipped} or {@code failed}. + * {@code skipReason} is set when the status is {@code skipped}: {@code window_closed}, + * {@code no_conversation} or {@code unsupported_platform}. {@code window_closed} means the + * network's messaging window had shut, so nothing was attempted. + */ +public record BroadcastRecipient( + String contactId, + String displayName, + String status, + String skipReason, + Instant sentAt, + String error) {} diff --git a/src/main/java/com/fopost/sdk/model/BroadcastSent.java b/src/main/java/com/fopost/sdk/model/BroadcastSent.java new file mode 100644 index 0000000..66f902e --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BroadcastSent.java @@ -0,0 +1,9 @@ +package com.fopost.sdk.model; + +/** + * What a send started. + * + *

{@code recipients} is how many contacts matched, not how many will be messaged — the + * messaging window decides that. + */ +public record BroadcastSent(String id, String status, int recipients) {} diff --git a/src/main/java/com/fopost/sdk/model/Contact.java b/src/main/java/com/fopost/sdk/model/Contact.java new file mode 100644 index 0000000..a78c94b --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/Contact.java @@ -0,0 +1,30 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * One person, however many handles they write from. + * + *

{@code source} is {@code inbox}, {@code radar} or {@code import} — what first created + * the row. {@code workspaceId} is set only on a listing that spans workspaces. + */ +public record Contact( + String id, + String displayName, + List channels, + String source, + String note, + Instant firstSeenAt, + Instant lastSeenAt, + Map fields, + List labels, + String workspaceId) { + + public Contact { + channels = channels == null ? List.of() : List.copyOf(channels); + labels = labels == null ? List.of() : List.copyOf(labels); + fields = fields == null ? Map.of() : Map.copyOf(fields); + } +} diff --git a/src/main/java/com/fopost/sdk/model/ContactChannel.java b/src/main/java/com/fopost/sdk/model/ContactChannel.java new file mode 100644 index 0000000..a2165dc --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactChannel.java @@ -0,0 +1,16 @@ +package com.fopost.sdk.model; + +/** + * One handle on one network. + * + *

{@code handle} is lower-cased with no leading {@code @}. {@code externalId} is the + * platform's own id for this person when the network gave us one, and it is what a merge + * prefers: a handle can be changed, an id cannot. + */ +public record ContactChannel(String platform, String handle, String externalId) { + + /** A channel without a platform id, which is all most writes need. */ + public static ContactChannel of(String platform, String handle) { + return new ContactChannel(platform, handle, null); + } +} diff --git a/src/main/java/com/fopost/sdk/model/ContactConversation.java b/src/main/java/com/fopost/sdk/model/ContactConversation.java new file mode 100644 index 0000000..30a9442 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactConversation.java @@ -0,0 +1,20 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One thread a contact appears in. + * + *

{@code key} is how the inbox groups it: the DM thread id, else the post the comments + * hang off, else the handle. {@code lastItemId} is an inbox item id. + */ +public record ContactConversation( + String key, + String accountId, + String accountUsername, + String platform, + int messages, + int received, + int sent, + Instant lastMessageAt, + String lastItemId) {} diff --git a/src/main/java/com/fopost/sdk/model/ContactField.java b/src/main/java/com/fopost/sdk/model/ContactField.java new file mode 100644 index 0000000..ced6e2c --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactField.java @@ -0,0 +1,17 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * A column the workspace invented to keep about its contacts. + * + *

{@code key} is the machine name and the CSV column header, fixed once created. + * {@code type} is {@code text}, {@code number}, {@code date}, {@code select} or + * {@code boolean}; {@code options} carries the allowed values when it is {@code select}. + */ +public record ContactField(String id, String key, String name, String type, List options, int position) { + + public ContactField { + options = options == null ? List.of() : List.copyOf(options); + } +} diff --git a/src/main/java/com/fopost/sdk/model/ContactImportResult.java b/src/main/java/com/fopost/sdk/model/ContactImportResult.java new file mode 100644 index 0000000..44b7a36 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactImportResult.java @@ -0,0 +1,19 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * What a CSV import did. + * + *

{@code merged} counts rows that folded into a contact already on file. + * {@code unknownColumns} names columns matching neither a reserved field nor a custom + * field; they are reported, never stored. + */ +public record ContactImportResult( + int created, int merged, List skipped, List unknownColumns) { + + public ContactImportResult { + skipped = skipped == null ? List.of() : List.copyOf(skipped); + unknownColumns = unknownColumns == null ? List.of() : List.copyOf(unknownColumns); + } +} diff --git a/src/main/java/com/fopost/sdk/model/ContactImportSkip.java b/src/main/java/com/fopost/sdk/model/ContactImportSkip.java new file mode 100644 index 0000000..b0baa87 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactImportSkip.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** One CSV row the import could not read. */ +public record ContactImportSkip(int row, String reason) {} diff --git a/src/main/java/com/fopost/sdk/model/ContactLabel.java b/src/main/java/com/fopost/sdk/model/ContactLabel.java new file mode 100644 index 0000000..dc41ac9 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactLabel.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** A workspace label put on a contact. */ +public record ContactLabel(String id, String name, String color) {} diff --git a/src/main/java/com/fopost/sdk/model/ContactPage.java b/src/main/java/com/fopost/sdk/model/ContactPage.java new file mode 100644 index 0000000..bce7b00 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactPage.java @@ -0,0 +1,25 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** One page of contacts: its rows plus the pagination block. Iterates over its rows. */ +public record ContactPage(List data, ContactPageMeta pagination) implements Iterable { + + public ContactPage { + data = data == null ? List.of() : List.copyOf(data); + } + + @Override + public Iterator iterator() { + return data.iterator(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } +} diff --git a/src/main/java/com/fopost/sdk/model/ContactPageMeta.java b/src/main/java/com/fopost/sdk/model/ContactPageMeta.java new file mode 100644 index 0000000..a26b6c0 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContactPageMeta.java @@ -0,0 +1,5 @@ +package com.fopost.sdk.model; + + +/** The pagination block a contacts listing returns, beside its data. */ +public record ContactPageMeta(Integer page, Integer perPage, Integer total) {} diff --git a/src/main/java/com/fopost/sdk/model/ConversationAnalytics.java b/src/main/java/com/fopost/sdk/model/ConversationAnalytics.java new file mode 100644 index 0000000..fd8c981 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ConversationAnalytics.java @@ -0,0 +1,12 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** Inbox analytics broken out per thread. */ +public record ConversationAnalytics( + List conversations, int total, int page, int perPage) { + + public ConversationAnalytics { + conversations = conversations == null ? List.of() : List.copyOf(conversations); + } +} diff --git a/src/main/java/com/fopost/sdk/model/ConversationAnalyticsRow.java b/src/main/java/com/fopost/sdk/model/ConversationAnalyticsRow.java new file mode 100644 index 0000000..73da848 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ConversationAnalyticsRow.java @@ -0,0 +1,20 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * How one thread performed over the period. + * + *

{@code medianResponseMinutes} is null when the thread was never answered. + */ +public record ConversationAnalyticsRow( + String key, + String accountId, + String platform, + int received, + int sent, + int answered, + int open, + Double medianResponseMinutes, + Instant firstMessageAt, + Instant lastMessageAt) {} diff --git a/src/main/java/com/fopost/sdk/model/Enrolled.java b/src/main/java/com/fopost/sdk/model/Enrolled.java new file mode 100644 index 0000000..b043123 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/Enrolled.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** How many contacts a call put on the sequence. */ +public record Enrolled(String id, int enrolled) {} diff --git a/src/main/java/com/fopost/sdk/model/Enrollment.java b/src/main/java/com/fopost/sdk/model/Enrollment.java new file mode 100644 index 0000000..22c1b19 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/Enrollment.java @@ -0,0 +1,20 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One contact walking one sequence. + * + *

{@code step} counts the steps already sent, so it is also the index of the next one. + * {@code status} is {@code active}, {@code completed}, {@code stopped} or {@code failed}. + * {@code error} carries the reason when a step was skipped rather than sent. + */ +public record Enrollment( + String id, + String contactId, + String displayName, + int step, + Instant nextAt, + String status, + Instant lastSentAt, + String error) {} diff --git a/src/main/java/com/fopost/sdk/model/EnrollmentCounts.java b/src/main/java/com/fopost/sdk/model/EnrollmentCounts.java new file mode 100644 index 0000000..ed7c157 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/EnrollmentCounts.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** Where a sequence's enrollments stand, by status. */ +public record EnrollmentCounts(int total, int active, int completed, int stopped, int failed) {} diff --git a/src/main/java/com/fopost/sdk/model/EnrollmentPage.java b/src/main/java/com/fopost/sdk/model/EnrollmentPage.java new file mode 100644 index 0000000..96cb457 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/EnrollmentPage.java @@ -0,0 +1,26 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** One page of a sequence's enrollments. Iterates over its rows. */ +public record EnrollmentPage(List data, BroadcastPageMeta pagination) + implements Iterable { + + public EnrollmentPage { + data = data == null ? List.of() : List.copyOf(data); + } + + @Override + public Iterator iterator() { + return data.iterator(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } +} diff --git a/src/main/java/com/fopost/sdk/model/RecipientPage.java b/src/main/java/com/fopost/sdk/model/RecipientPage.java new file mode 100644 index 0000000..0668022 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/RecipientPage.java @@ -0,0 +1,26 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** One page of a broadcast's recipients. Iterates over its rows. */ +public record RecipientPage(List data, BroadcastPageMeta pagination) + implements Iterable { + + public RecipientPage { + data = data == null ? List.of() : List.copyOf(data); + } + + @Override + public Iterator iterator() { + return data.iterator(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } +} diff --git a/src/main/java/com/fopost/sdk/model/Sequence.java b/src/main/java/com/fopost/sdk/model/Sequence.java new file mode 100644 index 0000000..4b7a3d1 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/Sequence.java @@ -0,0 +1,25 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * A series of messages, each a delay after the one before. + * + *

{@code status} is {@code active} or {@code paused}; a paused sequence fires nothing. + * {@code workspaceId} is set only on a listing that spans workspaces. + */ +public record Sequence( + String id, + String name, + String accountId, + List steps, + String status, + Instant createdAt, + EnrollmentCounts enrollments, + String workspaceId) { + + public Sequence { + steps = steps == null ? List.of() : List.copyOf(steps); + } +} diff --git a/src/main/java/com/fopost/sdk/model/SequencePage.java b/src/main/java/com/fopost/sdk/model/SequencePage.java new file mode 100644 index 0000000..10c1f13 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/SequencePage.java @@ -0,0 +1,26 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** One page of sequences. Iterates over its rows. */ +public record SequencePage(List data, BroadcastPageMeta pagination) + implements Iterable { + + public SequencePage { + data = data == null ? List.of() : List.copyOf(data); + } + + @Override + public Iterator iterator() { + return data.iterator(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } +} diff --git a/src/main/java/com/fopost/sdk/model/SequenceStep.java b/src/main/java/com/fopost/sdk/model/SequenceStep.java new file mode 100644 index 0000000..ba0ad35 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/SequenceStep.java @@ -0,0 +1,17 @@ +package com.fopost.sdk.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * One message and how long after the previous step it goes out. + * + *

{@code delayHours} on the first step is measured from the enrollment, so 0 means + * straight away. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SequenceStep(double delayHours, String text, String mediaId) { + + public static SequenceStep of(double delayHours, String text) { + return new SequenceStep(delayHours, text, null); + } +} diff --git a/src/main/java/com/fopost/sdk/model/Unenrolled.java b/src/main/java/com/fopost/sdk/model/Unenrolled.java new file mode 100644 index 0000000..80371e4 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/Unenrolled.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** How many enrollments a call stopped. */ +public record Unenrolled(String id, int stopped) {} diff --git a/src/main/java/com/fopost/sdk/param/BroadcastParams.java b/src/main/java/com/fopost/sdk/param/BroadcastParams.java new file mode 100644 index 0000000..1fa5603 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/BroadcastParams.java @@ -0,0 +1,293 @@ +package com.fopost.sdk.param; + +import com.fopost.sdk.model.AudienceFilter; +import com.fopost.sdk.model.SequenceStep; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Filters and bodies for {@code broadcasts()} and {@code sequences()}. + * + *

Each builder sends only what was set, so a patch stays partial. + */ +public final class BroadcastParams { + + private BroadcastParams() {} + + /** Filters for the broadcasts listing. */ + public static final class Filter { + + private final Map query = new LinkedHashMap<>(); + + public static Filter create() { + return new Filter(); + } + + /** Omit to span every workspace the key can reach. */ + public Filter workspace(String workspaceId) { + Params.put(query, "workspace_id", workspaceId); + return this; + } + + /** {@code draft}, {@code scheduled}, {@code sending}, {@code sent} or {@code cancelled}. */ + public Filter status(String status) { + Params.put(query, "status", status); + return this; + } + + public Filter page(int page) { + Params.put(query, "page", page); + return this; + } + + public Filter perPage(int perPage) { + Params.put(query, "per_page", perPage); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } + } + + /** Filters for a broadcast's recipients listing. */ + public static final class Recipients { + + private final Map query = new LinkedHashMap<>(); + + public static Recipients create() { + return new Recipients(); + } + + /** {@code pending}, {@code sent}, {@code skipped} or {@code failed}. */ + public Recipients status(String status) { + Params.put(query, "status", status); + return this; + } + + public Recipients page(int page) { + Params.put(query, "page", page); + return this; + } + + public Recipients perPage(int perPage) { + Params.put(query, "per_page", perPage); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } + } + + /** The body of a broadcast create. Creating never sends. */ + public static final class Create { + + private final Map body = new LinkedHashMap<>(); + + public static Create of(String workspaceId, String accountId, String name, String text) { + Create create = new Create(); + create.body.put("workspace_id", workspaceId); + create.body.put("account_id", accountId); + create.body.put("name", name); + create.body.put("text", text); + return create; + } + + public Create mediaId(String mediaId) { + Params.put(body, "media_id", mediaId); + return this; + } + + /** Omit to reach every contact in the workspace. */ + public Create audience(AudienceFilter audience) { + Params.put(body, "audience", audience); + return this; + } + + /** Send it at this time instead of on demand. */ + public Create scheduledAt(String scheduledAt) { + Params.put(body, "scheduled_at", scheduledAt); + return this; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } + } + + /** The body of a broadcast patch. Only a draft or scheduled broadcast can be edited. */ + public static final class Update { + + private final Map body = new LinkedHashMap<>(); + + public static Update create() { + return new Update(); + } + + public Update name(String name) { + Params.put(body, "name", name); + return this; + } + + public Update text(String text) { + Params.put(body, "text", text); + return this; + } + + public Update mediaId(String mediaId) { + body.put("media_id", mediaId); + return this; + } + + public Update audience(AudienceFilter audience) { + Params.put(body, "audience", audience); + return this; + } + + public Update scheduledAt(String scheduledAt) { + body.put("scheduled_at", scheduledAt); + return this; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } + } + + /** Filters for the sequences listing. */ + public static final class SequenceFilter { + + private final Map query = new LinkedHashMap<>(); + + public static SequenceFilter create() { + return new SequenceFilter(); + } + + public SequenceFilter workspace(String workspaceId) { + Params.put(query, "workspace_id", workspaceId); + return this; + } + + public SequenceFilter page(int page) { + Params.put(query, "page", page); + return this; + } + + public SequenceFilter perPage(int perPage) { + Params.put(query, "per_page", perPage); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } + } + + /** The body of a sequence create. Creating one enrolls nobody. */ + public static final class CreateSequence { + + private final Map body = new LinkedHashMap<>(); + + public static CreateSequence of( + String workspaceId, String accountId, String name, List steps) { + CreateSequence create = new CreateSequence(); + create.body.put("workspace_id", workspaceId); + create.body.put("account_id", accountId); + create.body.put("name", name); + create.body.put("steps", steps); + return create; + } + + /** {@code active} (the default) or {@code paused}. */ + public CreateSequence status(String status) { + Params.put(body, "status", status); + return this; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } + } + + /** + * The body of a sequence patch. + * + *

Pausing stops every enrollment from firing without ending any of them; resuming + * picks them up where they stood. + */ + public static final class UpdateSequence { + + private final Map body = new LinkedHashMap<>(); + + public static UpdateSequence create() { + return new UpdateSequence(); + } + + public UpdateSequence name(String name) { + Params.put(body, "name", name); + return this; + } + + public UpdateSequence steps(List steps) { + Params.put(body, "steps", steps); + return this; + } + + public UpdateSequence status(String status) { + Params.put(body, "status", status); + return this; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } + } + + /** Who to enroll: named contacts, or the audience they are drawn from. */ + public static final class Enroll { + + private final Map body = new LinkedHashMap<>(); + + public static Enroll contacts(List contactIds) { + Enroll enroll = new Enroll(); + enroll.body.put("contact_ids", contactIds); + return enroll; + } + + public static Enroll audience(AudienceFilter audience) { + Enroll enroll = new Enroll(); + enroll.body.put("audience", audience); + return enroll; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } + } + + /** Pagination for a sequence's enrollments listing. */ + public static final class Enrollments { + + private final Map query = new LinkedHashMap<>(); + + public static Enrollments create() { + return new Enrollments(); + } + + public Enrollments page(int page) { + Params.put(query, "page", page); + return this; + } + + public Enrollments perPage(int perPage) { + Params.put(query, "per_page", perPage); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } + } +} diff --git a/src/main/java/com/fopost/sdk/param/ContactParams.java b/src/main/java/com/fopost/sdk/param/ContactParams.java new file mode 100644 index 0000000..01396a9 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ContactParams.java @@ -0,0 +1,202 @@ +package com.fopost.sdk.param; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fopost.sdk.internal.Json; +import com.fopost.sdk.model.ContactChannel; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Filters and bodies for {@code contacts()}. + * + *

Each builder sends only what was set, so a patch stays partial. A custom field set to + * {@code null} through {@link Update#clearField} is cleared rather than left alone. + */ +public final class ContactParams { + + private ContactParams() {} + + /** Filters for the contacts listing. */ + public static final class Filter { + + private final Map query = new LinkedHashMap<>(); + + public static Filter create() { + return new Filter(); + } + + /** Omit to span every workspace the key can reach. */ + public Filter workspace(String workspaceId) { + Params.put(query, "workspace_id", workspaceId); + return this; + } + + /** Matches a display name or any of their handles. */ + public Filter search(String search) { + Params.put(query, "search", search); + return this; + } + + public Filter platform(String platform) { + Params.put(query, "platform", platform); + return this; + } + + /** {@code inbox}, {@code radar} or {@code import}. */ + public Filter source(String source) { + Params.put(query, "source", source); + return this; + } + + public Filter page(int page) { + Params.put(query, "page", page); + return this; + } + + public Filter perPage(int perPage) { + Params.put(query, "per_page", perPage); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } + } + + /** The body of a contact create. */ + public static final class Create { + + private final Map body = new LinkedHashMap<>(); + + public static Create of(String workspaceId, List channels) { + Create create = new Create(); + create.body.put("workspace_id", workspaceId); + create.body.put("channels", channels); + return create; + } + + public Create displayName(String displayName) { + Params.put(body, "display_name", displayName); + return this; + } + + public Create note(String note) { + Params.put(body, "note", note); + return this; + } + + public Create fields(Map fields) { + Params.put(body, "fields", fields); + return this; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } + } + + /** The body of a contact patch. Only what is set is sent. */ + public static final class Update { + + private final Map body = new LinkedHashMap<>(); + private final Map fields = new LinkedHashMap<>(); + private boolean touchedFields; + + public static Update create() { + return new Update(); + } + + public Update displayName(String displayName) { + body.put("display_name", displayName); + return this; + } + + public Update channels(List channels) { + Params.put(body, "channels", channels); + return this; + } + + public Update note(String note) { + body.put("note", note); + return this; + } + + public Update field(String key, String value) { + fields.put(key, value); + touchedFields = true; + return this; + } + + /** Clear one custom field. */ + public Update clearField(String key) { + fields.put(key, null); + touchedFields = true; + return this; + } + + public Map toBody() { + Map out = new LinkedHashMap<>(body); + if (touchedFields) { + // A plain Map would lose its nulls: the mapper is configured + // NON_NULL, and a cleared field has to reach the API as null. + ObjectNode node = Json.MAPPER.createObjectNode(); + fields.forEach((key, value) -> { + if (value == null) { + node.putNull(key); + } else { + node.put(key, value); + } + }); + out.put("fields", node); + } + return out; + } + } + + /** Filters for the per-conversation analytics report. */ + public static final class Conversations { + + private final Map query = new LinkedHashMap<>(); + + public static Conversations create() { + return new Conversations(); + } + + public Conversations workspace(String workspaceId) { + Params.put(query, "workspace_id", workspaceId); + return this; + } + + public Conversations account(String accountId) { + Params.put(query, "accountId", accountId); + return this; + } + + /** The reporting period, 1 to 365. Defaults to 7. */ + public Conversations days(int days) { + Params.put(query, "days", days); + return this; + } + + /** {@code volume} (default), {@code slowest} or {@code recent}. */ + public Conversations sort(String sort) { + Params.put(query, "sort", sort); + return this; + } + + public Conversations page(int page) { + Params.put(query, "page", page); + return this; + } + + public Conversations perPage(int perPage) { + Params.put(query, "per_page", perPage); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } + } +} diff --git a/src/main/java/com/fopost/sdk/resource/BroadcastsResource.java b/src/main/java/com/fopost/sdk/resource/BroadcastsResource.java new file mode 100644 index 0000000..fc268cb --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/BroadcastsResource.java @@ -0,0 +1,125 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.Broadcast; +import com.fopost.sdk.model.BroadcastPage; +import com.fopost.sdk.model.BroadcastSent; +import com.fopost.sdk.model.RecipientPage; +import com.fopost.sdk.param.BroadcastParams; +import java.util.Map; + +/** + * One message into every conversation the workspace already has with a segment of its + * contacts. + * + *

A broadcast is not a post and not a cold DM — every message lands in a direct-message + * thread the contact already started. + * + *

Nothing is sent into a closed messaging window. Messenger and Instagram take a + * business-initiated message only within 24 hours of the contact's last one, so recipients + * outside it come back skipped with {@code window_closed} rather than attempted, which is + * why the number sent is often lower than the audience. Telegram, Slack, Bluesky and Reddit + * have no window. + * + *

Reading needs the {@code inbox} scope; {@link #send} and {@link #cancel} also need + * {@code publish}. + */ +public final class BroadcastsResource { + + private final ApiClient http; + + public BroadcastsResource(ApiClient http) { + this.http = http; + } + + /** One page of broadcasts, newest first. */ + public BroadcastPage list() { + return list(BroadcastParams.Filter.create()); + } + + /** + * One page of broadcasts, newest first. + * + *

Omit the workspace to span every workspace the key can reach; each broadcast then + * carries {@code workspaceId}. + */ + public BroadcastPage list(BroadcastParams.Filter filter) { + return http.convert(http.get("/v1/broadcasts", filter.toQuery()), BroadcastPage.class); + } + + /** + * One broadcast. A broadcast in a workspace the key cannot reach answers 404, exactly as + * an id that never existed does. + */ + public Broadcast get(String broadcastId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/broadcasts/" + broadcastId, null)), Broadcast.class); + } + + /** + * Write a broadcast without sending it. + * + *

Set {@code scheduledAt} to have it go out on its own at that time; otherwise call + * {@link #send}. + */ + public Broadcast create(BroadcastParams.Create params) { + return http.convert( + ApiClient.unwrap(http.post("/v1/broadcasts", params.toBody())), Broadcast.class); + } + + /** Patch a broadcast. Only a draft or scheduled broadcast can be edited. */ + public Broadcast update(String broadcastId, BroadcastParams.Update params) { + return http.convert( + ApiClient.unwrap( + http.request("PATCH", "/v1/broadcasts/" + broadcastId, params.toBody(), null)), + Broadcast.class); + } + + /** + * Freeze the audience into a recipient list and start sending. + * + *

The returned {@code recipients} is how many contacts matched, not how many will be + * messaged — the messaging window decides that. Needs the {@code publish} scope as well + * as {@code inbox}. + */ + public BroadcastSent send(String broadcastId) { + return http.convert( + ApiClient.unwrap(http.post("/v1/broadcasts/" + broadcastId + "/send", Map.of())), + BroadcastSent.class); + } + + /** + * Stop a broadcast where it stands. + * + *

Anyone not yet written to stays unsent; messages already delivered are not recalled. + * Needs the {@code publish} scope. + */ + public Broadcast cancel(String broadcastId) { + return http.convert( + ApiClient.unwrap(http.post("/v1/broadcasts/" + broadcastId + "/cancel", Map.of())), + Broadcast.class); + } + + /** One row per contact, with what became of their message. */ + public RecipientPage recipients(String broadcastId) { + return recipients(broadcastId, BroadcastParams.Recipients.create()); + } + + /** + * One row per contact, with what became of their message. A skipped row carries its + * reason. + */ + public RecipientPage recipients(String broadcastId, BroadcastParams.Recipients filter) { + return http.convert( + http.get("/v1/broadcasts/" + broadcastId + "/recipients", filter.toQuery()), + RecipientPage.class); + } + + /** + * Remove a broadcast and its recipient records. Messages already sent stay in the + * conversations they went to. + */ + public void delete(String broadcastId) { + http.delete("/v1/broadcasts/" + broadcastId); + } +} diff --git a/src/main/java/com/fopost/sdk/resource/ContactsResource.java b/src/main/java/com/fopost/sdk/resource/ContactsResource.java new file mode 100644 index 0000000..69616f1 --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/ContactsResource.java @@ -0,0 +1,174 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.Contact; +import com.fopost.sdk.model.ContactConversation; +import com.fopost.sdk.model.ContactField; +import com.fopost.sdk.model.ContactImportResult; +import com.fopost.sdk.model.ContactPage; +import com.fopost.sdk.model.ConversationAnalytics; +import com.fopost.sdk.param.ContactParams; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The people behind the inbox, and the fields a workspace keeps about them. + * + *

A contact is one person however many handles they write from. An inbound inbox item + * files its author, a reply files whoever you answered, and both fold into whatever is + * already on file, so the same person never becomes two rows. + * + *

Every method needs the {@code inbox} scope, except {@link #conversationAnalytics}, + * which needs {@code analytics}. + */ +public final class ContactsResource { + + private final ApiClient http; + + public ContactsResource(ApiClient http) { + this.http = http; + } + + /** One page of contacts, most recently active first. */ + public ContactPage list() { + return list(ContactParams.Filter.create()); + } + + /** + * One page of contacts, most recently active first. + * + *

Omit the workspace to span every workspace the key can reach; each contact then + * carries {@code workspaceId}. + */ + public ContactPage list(ContactParams.Filter filter) { + return http.convert(http.get("/v1/contacts", filter.toQuery()), ContactPage.class); + } + + /** + * One contact. A contact in a workspace the key cannot reach answers 404, exactly as an + * id that never existed does. + */ + public Contact get(String contactId) { + return http.convert(ApiClient.unwrap(http.get("/v1/contacts/" + contactId, null)), Contact.class); + } + + /** + * File a contact. + * + *

It folds into the contact that already holds the first channel, so this cannot + * duplicate someone the inbox has already met. + */ + public Contact create(ContactParams.Create params) { + return http.convert(ApiClient.unwrap(http.post("/v1/contacts", params.toBody())), Contact.class); + } + + /** Patch a contact. Only what the builder set is sent. */ + public Contact update(String contactId, ContactParams.Update params) { + return http.convert( + ApiClient.unwrap(http.request("PATCH", "/v1/contacts/" + contactId, params.toBody(), null)), + Contact.class); + } + + /** + * Remove a contact and its field values. The messages they sent stay in the inbox, so a + * later message files them again. + */ + public void delete(String contactId) { + http.delete("/v1/contacts/" + contactId); + } + + /** The threads one contact appears in, newest first. */ + public List conversations(String contactId) { + return conversations(contactId, 0); + } + + /** + * The threads one contact appears in, newest first. Matched on their channels, so a + * contact merged from two handles brings both threads with it. A limit of zero leaves + * the server default. + */ + public List conversations(String contactId, int limit) { + Map query = new LinkedHashMap<>(); + if (limit > 0) { + query.put("limit", limit); + } + return http.convertList( + ApiClient.unwrap(http.get("/v1/contacts/" + contactId + "/conversations", query)), + ContactConversation.class); + } + + /** + * Import contacts from CSV text. + * + *

{@code platform} and {@code handle} are required columns; any other column is read + * as a custom field key, and one matching no field comes back in {@code unknownColumns} + * rather than being stored. + */ + public ContactImportResult importCsv(String workspaceId, String csv) { + Map body = new LinkedHashMap<>(); + body.put("workspace_id", workspaceId); + body.put("csv", csv); + return http.convert( + ApiClient.unwrap(http.post("/v1/contacts/import", body)), ContactImportResult.class); + } + + /** The columns this workspace keeps about its contacts, in display order. */ + public List listFields(String workspaceId) { + Map query = new LinkedHashMap<>(); + query.put("workspace_id", workspaceId); + return http.convertList( + ApiClient.unwrap(http.get("/v1/contacts/fields", query)), ContactField.class); + } + + /** + * Add a custom field. {@code type} is {@code text}, {@code number}, {@code date}, + * {@code select} or {@code boolean}; options are required for {@code select}. A + * duplicate key answers 409. + */ + public ContactField createField( + String workspaceId, String key, String name, String type, List options) { + Map query = new LinkedHashMap<>(); + query.put("workspace_id", workspaceId); + Map body = new LinkedHashMap<>(); + body.put("key", key); + body.put("name", name); + body.put("type", type); + body.put("options", options == null ? List.of() : options); + return http.convert( + ApiClient.unwrap(http.post("/v1/contacts/fields", body, query)), ContactField.class); + } + + /** Rename a field. The key and the type are fixed once created. */ + public ContactField updateField(String fieldId, String name) { + Map body = new LinkedHashMap<>(); + body.put("name", name); + return http.convert( + ApiClient.unwrap(http.request("PATCH", "/v1/contacts/fields/" + fieldId, body, null)), + ContactField.class); + } + + /** Replace a field's allowed values. */ + public ContactField updateFieldOptions(String fieldId, List options) { + Map body = new LinkedHashMap<>(); + body.put("options", options == null ? List.of() : options); + return http.convert( + ApiClient.unwrap(http.request("PATCH", "/v1/contacts/fields/" + fieldId, body, null)), + ContactField.class); + } + + /** Remove the field and every answer to it. */ + public void deleteField(String fieldId) { + http.delete("/v1/contacts/fields/" + fieldId); + } + + /** + * Volume and median reply time per thread. Counts and timings only: no message text and + * no author. Needs the {@code analytics} scope rather than {@code inbox}. + */ + public ConversationAnalytics conversationAnalytics(ContactParams.Conversations params) { + return http.convert( + ApiClient.unwrap(http.get("/v1/analytics/inbox/conversations", params.toQuery())), + ConversationAnalytics.class); + } +} diff --git a/src/main/java/com/fopost/sdk/resource/SequencesResource.java b/src/main/java/com/fopost/sdk/resource/SequencesResource.java new file mode 100644 index 0000000..cbda74b --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/SequencesResource.java @@ -0,0 +1,105 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.Enrolled; +import com.fopost.sdk.model.EnrollmentPage; +import com.fopost.sdk.model.Sequence; +import com.fopost.sdk.model.SequencePage; +import com.fopost.sdk.model.Unenrolled; +import com.fopost.sdk.param.BroadcastParams; +import java.util.List; +import java.util.Map; + +/** + * A series of messages, each a delay after the one before, walked per enrolled contact. + * + *

The messaging window applies to every step. A step that comes due outside it is skipped + * rather than sent, and the enrollment carries on — so someone can complete a sequence + * having received only some of its messages. + * + *

Reading needs the {@code inbox} scope; {@link #enroll} and {@link #unenroll} also need + * {@code publish}. + */ +public final class SequencesResource { + + private final ApiClient http; + + public SequencesResource(ApiClient http) { + this.http = http; + } + + /** One page of sequences. */ + public SequencePage list() { + return list(BroadcastParams.SequenceFilter.create()); + } + + /** One page of sequences. */ + public SequencePage list(BroadcastParams.SequenceFilter filter) { + return http.convert(http.get("/v1/sequences", filter.toQuery()), SequencePage.class); + } + + /** One sequence. */ + public Sequence get(String sequenceId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/sequences/" + sequenceId, null)), Sequence.class); + } + + /** Write a sequence. Creating one enrolls nobody. */ + public Sequence create(BroadcastParams.CreateSequence params) { + return http.convert( + ApiClient.unwrap(http.post("/v1/sequences", params.toBody())), Sequence.class); + } + + /** + * Patch a sequence. Pausing stops every enrollment from firing without ending any of + * them; resuming picks them up where they stood. + */ + public Sequence update(String sequenceId, BroadcastParams.UpdateSequence params) { + return http.convert( + ApiClient.unwrap( + http.request("PATCH", "/v1/sequences/" + sequenceId, params.toBody(), null)), + Sequence.class); + } + + /** + * Put contacts on the sequence, by id or by audience. + * + *

Re-enrolling someone restarts their walk from the first step rather than running two + * in parallel. Needs the {@code publish} scope as well as {@code inbox}. + */ + public Enrolled enroll(String sequenceId, BroadcastParams.Enroll who) { + return http.convert( + ApiClient.unwrap(http.post("/v1/sequences/" + sequenceId + "/enroll", who.toBody())), + Enrolled.class); + } + + /** + * Take contacts off the sequence. Nothing further fires for them. Needs the + * {@code publish} scope. + */ + public Unenrolled unenroll(String sequenceId, List contactIds) { + return http.convert( + ApiClient.unwrap( + http.post( + "/v1/sequences/" + sequenceId + "/unenroll", + Map.of("contact_ids", contactIds))), + Unenrolled.class); + } + + /** Who is on the sequence, what step they are at, and when the next one is due. */ + public EnrollmentPage enrollments(String sequenceId) { + return enrollments(sequenceId, BroadcastParams.Enrollments.create()); + } + + /** Who is on the sequence, what step they are at, and when the next one is due. */ + public EnrollmentPage enrollments(String sequenceId, BroadcastParams.Enrollments filter) { + return http.convert( + http.get("/v1/sequences/" + sequenceId + "/enrollments", filter.toQuery()), + EnrollmentPage.class); + } + + /** Remove a sequence and every enrollment on it. */ + public void delete(String sequenceId) { + http.delete("/v1/sequences/" + sequenceId); + } +} diff --git a/src/test/java/com/fopost/sdk/BroadcastsTest.java b/src/test/java/com/fopost/sdk/BroadcastsTest.java new file mode 100644 index 0000000..7f1af2f --- /dev/null +++ b/src/test/java/com/fopost/sdk/BroadcastsTest.java @@ -0,0 +1,159 @@ +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.AudienceFilter; +import com.fopost.sdk.model.Broadcast; +import com.fopost.sdk.model.BroadcastPage; +import com.fopost.sdk.model.BroadcastSent; +import com.fopost.sdk.model.Enrolled; +import com.fopost.sdk.model.RecipientPage; +import com.fopost.sdk.model.Sequence; +import com.fopost.sdk.model.SequenceStep; +import com.fopost.sdk.model.Unenrolled; +import com.fopost.sdk.param.BroadcastParams; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BroadcastsTest { + + private static final String BROADCAST = + """ + {"id":"bc_1","name":"September check-in","text":"New colours just landed.", + "account_id":"acc_1","audience":{"platforms":["instagram"]}, + "status":"sent","scheduled_at":null, + "sent_at":"2026-09-19T10:04:00.000Z","created_at":"2026-09-19T09:58:00.000Z", + "counts":{"total":3,"sent":2,"skipped":1,"failed":0,"pending":0}}"""; + + @Test + void listReadsThePaginationBlockRatherThanMeta() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 200, + "{\"data\":[" + BROADCAST + "],\"pagination\":{\"page\":2,\"per_page\":10,\"total\":11}}"); + + BroadcastPage page = + TestSupport.client(transport) + .broadcasts() + .list(BroadcastParams.Filter.create().workspace("w1").status("sent").page(2).perPage(10)); + + assertEquals( + "https://api.fopost.test/v1/broadcasts?workspace_id=w1&status=sent&page=2&per_page=10", + transport.last().url()); + assertEquals(1, page.size()); + Broadcast broadcast = page.data().get(0); + assertEquals("September check-in", broadcast.name()); + assertEquals(2, broadcast.counts().sent()); + assertEquals(1, broadcast.counts().skipped()); + assertEquals(11, page.pagination().total()); + } + + @Test + void createSendsTheSnakeCaseBody() { + FakeTransport transport = new FakeTransport().enqueue(201, "{\"data\":" + BROADCAST + "}"); + + TestSupport.client(transport) + .broadcasts() + .create( + BroadcastParams.Create.of("w1", "acc_1", "September check-in", "New colours just landed.") + .audience(AudienceFilter.all().platforms("instagram")) + .scheduledAt("2026-10-01T09:00:00.000Z")); + + String body = transport.lastBody(); + assertTrue(body.contains("\"workspace_id\":\"w1\""), body); + assertTrue(body.contains("\"account_id\":\"acc_1\""), body); + assertTrue(body.contains("\"scheduled_at\":\"2026-10-01T09:00:00.000Z\""), body); + assertTrue(body.contains("\"platforms\":[\"instagram\"]"), body); + } + + /** A closed messaging window has to be readable, or a non-send is a mystery. */ + @Test + void aSkippedRecipientKeepsItsReason() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 200, + """ + {"data":[{"contact_id":"con_1","display_name":"Sam Rivera", + "status":"skipped","skip_reason":"window_closed", + "sent_at":null,"error":null}], + "pagination":{"page":1,"per_page":50,"total":1}}"""); + + RecipientPage page = + TestSupport.client(transport) + .broadcasts() + .recipients("bc_1", BroadcastParams.Recipients.create().status("skipped")); + + assertTrue(transport.last().url().endsWith("/v1/broadcasts/bc_1/recipients?status=skipped"), + transport.last().url()); + assertEquals("skipped", page.data().get(0).status()); + assertEquals("window_closed", page.data().get(0).skipReason()); + } + + @Test + void sendReportsHowManyMatched() { + FakeTransport transport = + new FakeTransport() + .enqueue(200, "{\"data\":{\"id\":\"bc_1\",\"status\":\"sending\",\"recipients\":3}}"); + + BroadcastSent sent = TestSupport.client(transport).broadcasts().send("bc_1"); + + assertTrue(transport.last().url().endsWith("/v1/broadcasts/bc_1/send"), transport.last().url()); + assertEquals(3, sent.recipients()); + assertEquals("sending", sent.status()); + } + + @Test + void sequenceStepsTravelAsGiven() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 201, + """ + {"data":{"id":"seq_1","name":"Welcome","account_id":"acc_1", + "steps":[{"delay_hours":0,"text":"Hi"},{"delay_hours":48,"text":"Still here?"}], + "status":"active","created_at":"2026-09-12T08:00:00.000Z"}}"""); + + Sequence sequence = + TestSupport.client(transport) + .sequences() + .create( + BroadcastParams.CreateSequence.of( + "w1", "acc_1", "Welcome", List.of(SequenceStep.of(0, "Hi")))); + + assertEquals(48.0, sequence.steps().get(1).delayHours()); + String body = transport.lastBody(); + assertTrue(body.contains("\"delay_hours\":0.0") || body.contains("\"delay_hours\":0"), body); + assertTrue(body.contains("\"text\":\"Hi\""), body); + } + + @Test + void enrollTakesIdsOrAnAudience() { + FakeTransport transport = + new FakeTransport() + .enqueue(200, "{\"data\":{\"id\":\"seq_1\",\"enrolled\":2}}") + .enqueue(200, "{\"data\":{\"id\":\"seq_1\",\"enrolled\":5}}"); + FoPost client = TestSupport.client(transport); + + Enrolled byId = client.sequences().enroll("seq_1", BroadcastParams.Enroll.contacts(List.of("con_1", "con_2"))); + assertEquals(2, byId.enrolled()); + assertTrue(transport.lastBody().contains("\"contact_ids\":[\"con_1\",\"con_2\"]"), transport.lastBody()); + + client.sequences() + .enroll("seq_1", BroadcastParams.Enroll.audience(AudienceFilter.all().platforms("telegram"))); + assertTrue(transport.lastBody().contains("\"platforms\":[\"telegram\"]"), transport.lastBody()); + } + + @Test + void unenrollNamesTheContactsItStops() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{\"id\":\"seq_1\",\"stopped\":1}}"); + + Unenrolled stopped = TestSupport.client(transport).sequences().unenroll("seq_1", List.of("con_1")); + + assertTrue(transport.last().url().endsWith("/v1/sequences/seq_1/unenroll"), transport.last().url()); + assertTrue(transport.lastBody().contains("\"contact_ids\":[\"con_1\"]"), transport.lastBody()); + assertEquals(1, stopped.stopped()); + } +} diff --git a/src/test/java/com/fopost/sdk/ContactsTest.java b/src/test/java/com/fopost/sdk/ContactsTest.java new file mode 100644 index 0000000..793c310 --- /dev/null +++ b/src/test/java/com/fopost/sdk/ContactsTest.java @@ -0,0 +1,172 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.Contact; +import com.fopost.sdk.model.ContactChannel; +import com.fopost.sdk.model.ContactConversation; +import com.fopost.sdk.model.ContactField; +import com.fopost.sdk.model.ContactImportResult; +import com.fopost.sdk.model.ContactPage; +import com.fopost.sdk.model.ConversationAnalytics; +import com.fopost.sdk.param.ContactParams; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ContactsTest { + + private static final String CONTACT = + """ + {"id":"con_1","display_name":"Ada Okafor", + "channels":[{"platform":"instagram","handle":"adaokafor","externalId":"178414"}, + {"platform":"x","handle":"ada_writes","externalId":null}], + "source":"inbox","note":null, + "first_seen_at":"2026-04-02T09:14:00.000Z","last_seen_at":"2026-09-18T14:30:00.000Z", + "fields":{"plan_tier":"Pro"}, + "labels":[{"id":"lbl_1","name":"VIP","color":"#0070f3"}]}"""; + + @Test + void listReadsThePaginationBlockRatherThanMeta() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 200, + "{\"data\":[" + CONTACT + "],\"pagination\":{\"page\":2,\"per_page\":10,\"total\":11}}"); + + ContactPage page = + TestSupport.client(transport) + .contacts() + .list(ContactParams.Filter.create().workspace("w1").search("ada").page(2).perPage(10)); + + assertEquals( + "https://api.fopost.test/v1/contacts?workspace_id=w1&search=ada&page=2&per_page=10", + transport.last().url()); + assertEquals(1, page.size()); + assertFalse(page.isEmpty()); + Contact contact = page.data().get(0); + assertEquals("Ada Okafor", contact.displayName()); + assertEquals("178414", contact.channels().get(0).externalId()); + assertEquals("Pro", contact.fields().get("plan_tier")); + assertEquals("VIP", contact.labels().get(0).name()); + assertEquals(11, page.pagination().total()); + assertEquals(2, page.pagination().page()); + } + + @Test + void createSendsTheSnakeCaseWireNames() { + FakeTransport transport = new FakeTransport().enqueue(201, "{\"data\":" + CONTACT + "}"); + + TestSupport.client(transport) + .contacts() + .create( + ContactParams.Create.of("w1", List.of(ContactChannel.of("x", "ada_writes"))) + .displayName("Ada Okafor") + .fields(java.util.Map.of("plan_tier", "Pro"))); + + String body = transport.lastBody(); + assertTrue(body.contains("\"workspace_id\":\"w1\""), body); + assertTrue(body.contains("\"display_name\":\"Ada Okafor\""), body); + // An absent platform id must not travel as null: it would claim we know one. + assertFalse(body.contains("external_id"), body); + } + + @Test + void updateClearsAFieldWithNullAndSendsNothingElse() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + CONTACT + "}"); + + TestSupport.client(transport) + .contacts() + .update("con_1", ContactParams.Update.create().clearField("region")); + + assertEquals("PATCH", transport.last().method()); + assertEquals("{\"fields\":{\"region\":null}}", transport.lastBody()); + } + + @Test + void conversationsReadsTheThreadsAContactAppearsIn() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 200, + """ + {"data":[{"key":"t_182736","account_id":"acc_1","account_username":"yourbrand", + "platform":"instagram","messages":14,"received":9,"sent":5, + "last_message_at":"2026-09-18T14:30:00.000Z","last_item_id":"inb_1"}]}"""); + + List rows = + TestSupport.client(transport).contacts().conversations("con_1", 10); + + assertEquals( + "https://api.fopost.test/v1/contacts/con_1/conversations?limit=10", transport.last().url()); + assertEquals(1, rows.size()); + assertEquals("t_182736", rows.get(0).key()); + assertEquals(9, rows.get(0).received()); + } + + @Test + void importReportsWhatMergedAndWhatWasSkipped() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 200, + """ + {"data":{"created":1,"merged":2, + "skipped":[{"row":4,"reason":"platform and handle are both required"}], + "unknownColumns":["lifetime_value"]}}"""); + + ContactImportResult result = + TestSupport.client(transport).contacts().importCsv("w1", "platform,handle\nx,ada_writes"); + + assertEquals(1, result.created()); + assertEquals(2, result.merged()); + assertEquals(4, result.skipped().get(0).row()); + assertEquals(List.of("lifetime_value"), result.unknownColumns()); + } + + @Test + void createFieldPutsTheWorkspaceOnTheQuery() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 201, + """ + {"data":{"id":"fld_1","key":"plan_tier","name":"Plan Tier", + "type":"select","options":["Free","Pro"],"position":0}}"""); + + ContactField field = + TestSupport.client(transport) + .contacts() + .createField("w1", "plan_tier", "Plan Tier", "select", List.of("Free", "Pro")); + + assertEquals( + "https://api.fopost.test/v1/contacts/fields?workspace_id=w1", transport.last().url()); + assertEquals("plan_tier", field.key()); + assertEquals(List.of("Free", "Pro"), field.options()); + } + + @Test + void conversationAnalyticsReadsTheAnalyticsRoute() { + FakeTransport transport = + new FakeTransport() + .enqueue( + 200, + """ + {"data":{"conversations":[{"key":"t_1","accountId":"acc_1","platform":"instagram", + "received":9,"sent":5,"answered":5,"open":1, + "medianResponseMinutes":47,"firstMessageAt":null,"lastMessageAt":null}], + "total":128,"page":1,"perPage":25}}"""); + + ConversationAnalytics report = + TestSupport.client(transport) + .contacts() + .conversationAnalytics(ContactParams.Conversations.create().days(30).sort("slowest")); + + assertEquals( + "https://api.fopost.test/v1/analytics/inbox/conversations?days=30&sort=slowest", + transport.last().url()); + assertEquals(128, report.total()); + assertEquals(47.0, report.conversations().get(0).medianResponseMinutes()); + } +}