Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ All notable changes to this project are documented here. The format follows

### Added

- `inbox().repost`, `unrepost`, `bookmark` and `unbookmark` for X items, with `reposted`,
`bookmarked`, `canRepost` and `canBookmark` on `InboxItem`. Need `inbox` and `publish`.
- `accounts().followXUser(accountId, userId)` and `unfollowXUser` (`XFollowResult`), and the X
reads `getXPost(postId, accountId)` (`XPost`) and `searchXPosts(XSearchParams)` (`XSearchPage`,
paged by `meta().nextToken()`). Follow needs `inbox` and `publish`; the reads need `inbox`.
- A 402 `platform_balance_spent` on any of these means X's own pay-per-use balance is spent, and
a 409 `reconnect_required` means the X account was connected before the repost, bookmark and
follow grants and has to be reconnected. Every call is an X API call metered to the workspace.
- `ads().sendPixelConversions(params, idempotencyKey)` sends Conversions API events with a
required `Idempotency-Key`; a replay answers `ConversionUploadResult.replayed()` true.
`ads().listConversionBatches` lists what was stored, with hashed identifiers only.
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `posts()` | `list`, `autoPaginate`, `stream`, `get`, `create`, `update`, `delete`, `duplicate`, `publish`, `retry`, `cancel`, `preflight`, `deliveries`, `publishRuns`, `analytics`, `bulkShift`, `bulkLabel`, `bulkDelete`, `validateImport`, `commitImport`, `rollbackImport`, `unpublish` |
| `workspaces()` | `list`, `get`, `create`, `update`, `delete`, `analytics` |
| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `redditSubreddits`, `redditSubredditRules`, `redditFlairs`, `setRedditDefaultSubreddit`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole`, `platformMetrics` |
| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `redditSubreddits`, `redditSubredditRules`, `redditFlairs`, `setRedditDefaultSubreddit`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole`, `platformMetrics`, `followXUser`, `unfollowXUser`, `getXPost`, `searchXPosts` |
| `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` |
| `labels()` | `list`, `get`, `create`, `update`, `delete` |
| `webhooks()` | `list`, `create`, `update`, `delete`, `test`, `deliveries`, `delivery`, `redeliver`, `verify` |
Expand All @@ -108,13 +108,13 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats`, `versions`, `version`, `restoreVersion` |
| `media()` | `list`, `limits`, `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`, `vote`, `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`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `vote`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `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`, `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`, `uploadConversions`, `sendPixelConversions`, `listConversionBatches`, `pixels`, `createPixel`, `pixel`, `updatePixel`, `sharePixel`, `pixelStats`, `eventMatchQuality`, `customConversions`, `createCustomConversion`, `customConversion`, `updateCustomConversion`, `deleteCustomConversion` |
| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `vote`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` |
| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `vote`, `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` |
Expand Down Expand Up @@ -369,10 +369,30 @@ drafted that a person still has to send; `approveReply` sends the draft (or your
`rejectReply` discards it.

The actions that act on the platform as the account also need the `publish` scope: `like`,
`unlike`, `pin`, `unpin`, `react`, `editComment`, `startConversation`, `setTyping`, `handover`, a `reply` with
`unlike`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `pin`, `unpin`, `react`, `editComment`, `startConversation`, `setTyping`, `handover`, a `reply` with
`InboxReplyParams` carrying `mediaIds` or `quickReplies`, and deleting our own reply. Each works only
where the item's matching `can*` flag is true.

### X engagement

On X an item can also be reposted or bookmarked (`canRepost`, `canBookmark`), and an account can
follow or unfollow a user by their numeric X user id. `getXPost` and `searchXPosts` read X directly
with the account's token; the search is paged by `meta().nextToken()`.

```java
client.inbox().repost(item.id());
client.accounts().followXUser(accountId, "2244994945");

XPost post = client.accounts().getXPost("1790000000000000000", accountId);
XSearchPage page = client.accounts().searchXPosts(
XSearchParams.create(accountId, "from:yourbrand -is:retweet").maxResults(50));
```

Follow, repost and bookmark need `publish`; the reads need `inbox`. Every one of these is an X API
call metered to the workspace. An X account connected before these actions existed must be
reconnected first, and answers `409 reconnect_required` until it is; a spent X balance answers
`402 platform_balance_spent`.

## 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.
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/fopost/sdk/model/InboxItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
* The {@code can*} flags say which actions the platform allows on it. {@code canDelete} covers a
* comment someone left or our own reply; {@code canPin} and {@code canEdit} are our own comments
* only; {@code canPrivateReply} means a DM can be opened from it with
* {@code StartConversationParams.privateReply}. {@code reaction} is our reaction on a DM.
* {@code StartConversationParams.privateReply}. {@code reaction} is our reaction on a DM. {@code reposted} and
* {@code bookmarked}, with {@code canRepost} and {@code canBookmark}, are X only.
* {@code moderationStatus} is the platform's own state for a comment: published, held, spam or
* rejected, and null where the platform does not report one.
*/
Expand Down Expand Up @@ -41,11 +42,15 @@ public record InboxItem(
Boolean canHide,
Boolean canDelete,
Boolean liked,
Boolean reposted,
Boolean bookmarked,
String vote,
Boolean pinned,
String reaction,
Instant editedAt,
Boolean canLike,
Boolean canRepost,
Boolean canBookmark,
Boolean canVote,
Boolean canPin,
Boolean canEdit,
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/XFollowResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** The follow state of an X user after {@code followXUser} or {@code unfollowXUser}. */
public record XFollowResult(String accountId, String userId, Boolean following) {}
25 changes: 25 additions & 0 deletions src/main/java/com/fopost/sdk/model/XPost.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fopost.sdk.model;

import java.time.Instant;

/**
* A post read from X with a connected account's token.
*
* <p>{@code conversationId} is the thread root and {@code parentExternalId} the post this one
* replied to; both are null on a standalone post. The counts are what X reported at read time.
*/
public record XPost(
String id,
String text,
String permalink,
String authorName,
String authorHandle,
String authorAvatarUrl,
Long authorFollowers,
String conversationId,
String parentExternalId,
Long likes,
Long replies,
Long reposts,
Long quotes,
Instant platformCreatedAt) {}
25 changes: 25 additions & 0 deletions src/main/java/com/fopost/sdk/model/XSearchPage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fopost.sdk.model;

import java.util.Iterator;
import java.util.List;

/** One page of an X search: the posts plus the cursor for the next page. Iterates over its posts. */
public record XSearchPage(List<XPost> data, XSearchPageMeta meta) implements Iterable<XPost> {

public XSearchPage {
data = data == null ? List.of() : List.copyOf(data);
}

@Override
public Iterator<XPost> iterator() {
return data.iterator();
}

public int size() {
return data.size();
}

public boolean isEmpty() {
return data.isEmpty();
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/XSearchPageMeta.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** The cursor on an X search: pass {@code nextToken} back to read the next page, null at the end. */
public record XSearchPageMeta(String nextToken) {}
36 changes: 36 additions & 0 deletions src/main/java/com/fopost/sdk/param/XSearchParams.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.fopost.sdk.param;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* An X recent search. {@code accountId} and {@code q} are required: the X account whose token does
* the read, and a query of 1 to 512 characters in X's own search syntax.
*/
public final class XSearchParams {

private final Map<String, Object> query = new LinkedHashMap<>();

public static XSearchParams create(String accountId, String q) {
XSearchParams params = new XSearchParams();
params.query.put("account_id", accountId);
params.query.put("q", q);
return params;
}

/** 10 to 100 posts per page. */
public XSearchParams maxResults(int maxResults) {
query.put("max_results", maxResults);
return this;
}

/** The {@code nextToken} from the previous page's meta. */
public XSearchParams nextToken(String nextToken) {
Params.put(query, "next_token", nextToken);
return this;
}

public Map<String, Object> toQuery() {
return new LinkedHashMap<>(query);
}
}
Loading
Loading