diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc186f..c178114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 46229a2..52d985c 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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` | @@ -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. diff --git a/src/main/java/com/fopost/sdk/model/InboxItem.java b/src/main/java/com/fopost/sdk/model/InboxItem.java index f358909..42110d0 100644 --- a/src/main/java/com/fopost/sdk/model/InboxItem.java +++ b/src/main/java/com/fopost/sdk/model/InboxItem.java @@ -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. */ @@ -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, diff --git a/src/main/java/com/fopost/sdk/model/XFollowResult.java b/src/main/java/com/fopost/sdk/model/XFollowResult.java new file mode 100644 index 0000000..30680d0 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/XFollowResult.java @@ -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) {} diff --git a/src/main/java/com/fopost/sdk/model/XPost.java b/src/main/java/com/fopost/sdk/model/XPost.java new file mode 100644 index 0000000..e0e39e7 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/XPost.java @@ -0,0 +1,25 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * A post read from X with a connected account's token. + * + *

{@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) {} diff --git a/src/main/java/com/fopost/sdk/model/XSearchPage.java b/src/main/java/com/fopost/sdk/model/XSearchPage.java new file mode 100644 index 0000000..f1d0f93 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/XSearchPage.java @@ -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 data, XSearchPageMeta meta) implements Iterable { + + public XSearchPage { + 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/XSearchPageMeta.java b/src/main/java/com/fopost/sdk/model/XSearchPageMeta.java new file mode 100644 index 0000000..73d4566 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/XSearchPageMeta.java @@ -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) {} diff --git a/src/main/java/com/fopost/sdk/param/XSearchParams.java b/src/main/java/com/fopost/sdk/param/XSearchParams.java new file mode 100644 index 0000000..f2029b7 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/XSearchParams.java @@ -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 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 toQuery() { + return new LinkedHashMap<>(query); + } +} diff --git a/src/main/java/com/fopost/sdk/resource/AccountsResource.java b/src/main/java/com/fopost/sdk/resource/AccountsResource.java index d05364c..482953c 100644 --- a/src/main/java/com/fopost/sdk/resource/AccountsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AccountsResource.java @@ -49,6 +49,9 @@ import com.fopost.sdk.model.TikTokVideoSource; import com.fopost.sdk.model.TokenRefresh; import com.fopost.sdk.model.WebhookSubscription; +import com.fopost.sdk.model.XFollowResult; +import com.fopost.sdk.model.XPost; +import com.fopost.sdk.model.XSearchPage; import com.fopost.sdk.param.CreateAccountParams; import com.fopost.sdk.param.DiscordEventParams; import com.fopost.sdk.param.DiscordRoleParams; @@ -61,6 +64,7 @@ import com.fopost.sdk.param.CreateYouTubePlaylistParams; import com.fopost.sdk.param.UpdateSlackIdentityParams; import com.fopost.sdk.param.UploadYouTubeCaptionsParams; +import com.fopost.sdk.param.XSearchParams; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -164,6 +168,46 @@ public AccountHealth health(String accountId, boolean refresh) { ApiClient.unwrap(http.get("/v1/accounts/" + accountId + "/health", params)), AccountHealth.class); } + /** + * Follow an X user as this account. {@code userId} is the numeric X user id. Needs the + * {@code inbox} and {@code publish} scopes. A non-X account is a 404; an X account connected + * before the follow grant answers 409 {@code reconnect_required} until it is reconnected; a + * spent X balance answers 402 {@code platform_balance_spent}. + */ + public XFollowResult followXUser(String accountId, String userId) { + return xFollow(accountId, "/x/follow", userId); + } + + /** Unfollow an X user as this account. Same scopes and errors as {@code followXUser}. */ + public XFollowResult unfollowXUser(String accountId, String userId) { + return xFollow(accountId, "/x/unfollow", userId); + } + + private XFollowResult xFollow(String accountId, String action, String userId) { + Map body = new LinkedHashMap<>(); + body.put("user_id", userId); + return http.convert( + ApiClient.unwrap(http.post("/v1/accounts/" + accountId + action, body)), XFollowResult.class); + } + + /** + * One X post by its id, read with {@code accountId}'s token. Needs the {@code inbox} scope. + * 404 when X has no such post. Every read is an X API call metered to the workspace. + */ + public XPost getXPost(String postId, String accountId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/x/posts/" + postId, query("account_id", accountId))), XPost.class); + } + + /** + * Recent X posts matching a query, read with the account's token. Needs the {@code inbox} + * scope. Pass {@code meta().nextToken()} back through {@code XSearchParams.nextToken} for the + * next page. Every read is an X API call metered to the workspace. + */ + public XSearchPage searchXPosts(XSearchParams params) { + return http.convert(http.get("/v1/x/search", params.toQuery()), XSearchPage.class); + } + /** * The numbers only this account's network reports, in its own vocabulary: ad-break * earnings, story taps, a retention curve, the search terms behind a listing. Keyed by diff --git a/src/main/java/com/fopost/sdk/resource/InboxResource.java b/src/main/java/com/fopost/sdk/resource/InboxResource.java index ef1320f..f3e28a8 100644 --- a/src/main/java/com/fopost/sdk/resource/InboxResource.java +++ b/src/main/java/com/fopost/sdk/resource/InboxResource.java @@ -40,8 +40,8 @@ * } * *

Every call needs the {@code inbox} scope on an API key. The calls that act on the platform as - * the account (like, pin, react, edit, start a conversation, typing, a reply with media or quick - * replies, deleting our own reply) also need the {@code publish} scope. + * the account (like, repost, bookmark, pin, react, edit, start a conversation, typing, a reply + * with media or quick replies, deleting our own reply) also need the {@code publish} scope. */ public final class InboxResource { @@ -215,6 +215,31 @@ public InboxItem unlike(String itemId) { return http.convert(ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/unlike", null)), InboxItem.class); } + /** + * Repost to the account's own timeline (X). Only where {@code canRepost} is true. An X account + * connected before the repost grant answers 409 {@code reconnect_required} until it is + * reconnected; a spent X balance answers 402 {@code platform_balance_spent}. + */ + public InboxItem repost(String itemId) { + return http.convert(ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/repost", null)), InboxItem.class); + } + + public InboxItem unrepost(String itemId) { + return http.convert( + ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/unrepost", null)), InboxItem.class); + } + + /** Bookmark on the account (X). Only where {@code canBookmark} is true; same errors as repost. */ + public InboxItem bookmark(String itemId) { + return http.convert( + ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/bookmark", null)), InboxItem.class); + } + + public InboxItem unbookmark(String itemId) { + return http.convert( + ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/unbookmark", null)), InboxItem.class); + } + /** Pin our own comment. Only where {@code canPin} is true. */ public InboxItem pin(String itemId) { return http.convert(ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/pin", null)), InboxItem.class); diff --git a/src/test/java/com/fopost/sdk/XEngagementTest.java b/src/test/java/com/fopost/sdk/XEngagementTest.java new file mode 100644 index 0000000..00318ac --- /dev/null +++ b/src/test/java/com/fopost/sdk/XEngagementTest.java @@ -0,0 +1,124 @@ +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.InboxItem; +import com.fopost.sdk.model.XFollowResult; +import com.fopost.sdk.model.XPost; +import com.fopost.sdk.model.XSearchPage; +import com.fopost.sdk.param.XSearchParams; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class XEngagementTest { + + @Test + void repostAndBookmarkPostWithoutABodyAndReadTheNewFlags() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"id":"i1","platform":"twitter","liked":false,"reposted":true, + "bookmarked":false,"can_like":true,"can_repost":true,"can_bookmark":true}}""") + .enqueue(200, "{\"data\":{\"id\":\"i1\",\"reposted\":false}}") + .enqueue(200, "{\"data\":{\"id\":\"i1\",\"bookmarked\":true,\"canBookmark\":true}}") + .enqueue(200, "{\"data\":{\"id\":\"i1\",\"bookmarked\":false}}"); + FoPost client = TestSupport.client(transport); + + InboxItem reposted = client.inbox().repost("i1"); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/inbox/i1/repost", transport.last().url()); + assertEquals("", transport.lastBody()); + assertTrue(reposted.reposted()); + assertFalse(reposted.bookmarked()); + assertTrue(reposted.canRepost()); + assertTrue(reposted.canBookmark()); + + assertFalse(client.inbox().unrepost("i1").reposted()); + assertEquals("https://api.fopost.test/v1/inbox/i1/unrepost", transport.last().url()); + + InboxItem bookmarked = client.inbox().bookmark("i1"); + assertEquals("https://api.fopost.test/v1/inbox/i1/bookmark", transport.last().url()); + assertTrue(bookmarked.bookmarked()); + assertTrue(bookmarked.canBookmark()); + + assertFalse(client.inbox().unbookmark("i1").bookmarked()); + assertEquals("https://api.fopost.test/v1/inbox/i1/unbookmark", transport.last().url()); + } + + @Test + void followAndUnfollowSendTheUserIdAndReadTheState() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":{\"account_id\":\"a1\",\"user_id\":\"2244994945\",\"following\":true}}") + .enqueue(200, "{\"data\":{\"account_id\":\"a1\",\"user_id\":\"2244994945\",\"following\":false}}"); + FoPost client = TestSupport.client(transport); + + XFollowResult followed = client.accounts().followXUser("a1", "2244994945"); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/accounts/a1/x/follow", transport.last().url()); + assertEquals("{\"user_id\":\"2244994945\"}", transport.lastBody()); + assertEquals("a1", followed.accountId()); + assertEquals("2244994945", followed.userId()); + assertTrue(followed.following()); + + XFollowResult unfollowed = client.accounts().unfollowXUser("a1", "2244994945"); + assertEquals("https://api.fopost.test/v1/accounts/a1/x/unfollow", transport.last().url()); + assertEquals("{\"user_id\":\"2244994945\"}", transport.lastBody()); + assertFalse(unfollowed.following()); + } + + @Test + void postLookupNamesTheAccountItReadsAs() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"id":"1790000000000000000","text":"Shipping today", + "permalink":"https://x.com/yourbrand/status/1790000000000000000", + "author_name":"Your Brand","author_handle":"yourbrand", + "author_avatar_url":null,"author_followers":1200, + "conversation_id":"1790000000000000000","parent_external_id":null, + "likes":10,"replies":2,"reposts":3,"quotes":1, + "platform_created_at":"2026-10-18T09:00:00.000Z"}}"""); + FoPost client = TestSupport.client(transport); + + XPost post = client.accounts().getXPost("1790000000000000000", "a1"); + + assertEquals("GET", transport.last().method()); + assertEquals("https://api.fopost.test/v1/x/posts/1790000000000000000?account_id=a1", transport.last().url()); + assertEquals("Shipping today", post.text()); + assertEquals("yourbrand", post.authorHandle()); + assertEquals(1200L, post.authorFollowers()); + assertEquals(3L, post.reposts()); + assertNull(post.parentExternalId()); + assertEquals(Instant.parse("2026-10-18T09:00:00Z"), post.platformCreatedAt()); + } + + @Test + void searchEncodesTheQueryAndCursorAndReadsTheNextToken() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":[{"id":"1","text":"first"},{"id":"2","text":"second"}], + "meta":{"next_token":"b26v89c19zqg8o3fpdy"}}""") + .enqueue(200, "{\"data\":[],\"meta\":{\"next_token\":null}}"); + FoPost client = TestSupport.client(transport); + + XSearchPage page = client.accounts().searchXPosts( + XSearchParams.create("a1", "from:yourbrand -is:retweet").maxResults(50)); + + assertEquals("GET", transport.last().method()); + assertEquals( + "https://api.fopost.test/v1/x/search?account_id=a1&q=from%3Ayourbrand+-is%3Aretweet&max_results=50", + transport.last().url()); + assertEquals(2, page.size()); + assertEquals("second", page.data().get(1).text()); + assertEquals("b26v89c19zqg8o3fpdy", page.meta().nextToken()); + + XSearchPage last = client.accounts().searchXPosts( + XSearchParams.create("a1", "from:yourbrand").nextToken(page.meta().nextToken())); + assertEquals( + "https://api.fopost.test/v1/x/search?account_id=a1&q=from%3Ayourbrand&next_token=b26v89c19zqg8o3fpdy", + transport.last().url()); + assertTrue(last.isEmpty()); + assertNull(last.meta().nextToken()); + } +}