From e93630b6caa1a4c6c5ae36795e63c47436a623b9 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:47:58 +0200 Subject: [PATCH 1/3] feat(inbox): repost, unrepost, bookmark and unbookmark on X items --- src/fopost/models.py | 41 +++++++++++++++++++++++++++++++++++ src/fopost/resources/inbox.py | 14 ++++++++++++ tests/test_inbox.py | 28 ++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/src/fopost/models.py b/src/fopost/models.py index c84d48e..2f69648 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -126,6 +126,9 @@ "SlackMember", "SocialAccount", "SubredditCheck", + "XFollowResult", + "XPost", + "XSearchResult", "TargetingOption", "TelegramBotCommand", "TelegramBotCommands", @@ -927,6 +930,40 @@ class SubredditCheck(FopostModel): ok: bool = False +class XPost(FopostModel): + """A post read from X with the account's own token; every X call is metered.""" + + id: str + text: str | None = None + permalink: str | None = None + author_name: str | None = None + author_handle: str | None = None + author_avatar_url: str | None = None + author_followers: int | None = None + #: Thread root id. + conversation_id: str | None = None + #: The post this one replied to. + parent_external_id: str | None = None + likes: int | None = None + replies: int | None = None + reposts: int | None = None + quotes: int | None = None + platform_created_at: datetime | None = None + + +class XSearchResult(FopostModel): + """One page of recent X posts; pass ``next_token`` back to read the next.""" + + posts: list[XPost] = [] + next_token: str | None = None + + +class XFollowResult(FopostModel): + account_id: str + user_id: str + following: bool + + class Workspace(FopostModel): id: str name: str @@ -1273,8 +1310,12 @@ class InboxItem(FopostModel): pinned: bool | None = None reaction: str | None = None edited_at: datetime | None = None + reposted: bool | None = None + bookmarked: bool | None = None can_like: bool | None = None can_vote: bool | None = None + can_repost: bool | None = None + can_bookmark: bool | None = None can_pin: bool | None = None can_edit: bool | None = None can_react: bool | None = None diff --git a/src/fopost/resources/inbox.py b/src/fopost/resources/inbox.py index 0cf0643..3fc96f2 100644 --- a/src/fopost/resources/inbox.py +++ b/src/fopost/resources/inbox.py @@ -228,6 +228,20 @@ def like(self, item_id: str) -> InboxItem: def unlike(self, item_id: str) -> InboxItem: return InboxItem.model_validate(unwrap(self._http.post(f"/inbox/{item_id}/unlike"))) + def repost(self, item_id: str) -> InboxItem: + """Repost the item to the account's own timeline, where ``can_repost`` is true.""" + return InboxItem.model_validate(unwrap(self._http.post(f"/inbox/{item_id}/repost"))) + + def unrepost(self, item_id: str) -> InboxItem: + return InboxItem.model_validate(unwrap(self._http.post(f"/inbox/{item_id}/unrepost"))) + + def bookmark(self, item_id: str) -> InboxItem: + """Bookmark the item on the account, where ``can_bookmark`` is true.""" + return InboxItem.model_validate(unwrap(self._http.post(f"/inbox/{item_id}/bookmark"))) + + def unbookmark(self, item_id: str) -> InboxItem: + return InboxItem.model_validate(unwrap(self._http.post(f"/inbox/{item_id}/unbookmark"))) + def vote(self, item_id: str, direction: str) -> InboxItem: """Vote up or down where the network ranks by votes, where ``can_vote`` is true. diff --git a/tests/test_inbox.py b/tests/test_inbox.py index 603d290..ea0c30e 100644 --- a/tests/test_inbox.py +++ b/tests/test_inbox.py @@ -204,6 +204,34 @@ def test_like_pin_react_and_edit_return_the_item(client: Fopost) -> None: assert json.loads(edit.calls.last.request.content) == {"text": "Fixed typo"} +@respx.mock +def test_repost_and_bookmark_return_the_x_item(client: Fopost) -> None: + acted = { + **ITEM_FIXTURE, + "platform": "twitter", + "reposted": True, + "bookmarked": True, + "canRepost": True, + "canBookmark": True, + } + routes = { + name: respx.post(f"{BASE_URL}/inbox/item_1/{name}").mock( + return_value=httpx.Response(200, json={"data": acted}) + ) + for name in ("repost", "unrepost", "bookmark", "unbookmark") + } + + item = client.inbox.repost("item_1") + assert item.reposted is True + assert item.can_repost is True + assert client.inbox.unrepost("item_1").id == "item_1" + assert client.inbox.bookmark("item_1").bookmarked is True + assert client.inbox.unbookmark("item_1").can_bookmark is True + for name, route in routes.items(): + assert route.called, name + assert route.calls.last.request.content == b"" + + @respx.mock def test_reply_with_media_and_quick_replies(client: Fopost) -> None: reply = respx.post(f"{BASE_URL}/inbox/item_1/reply").mock( From d72ca21ac00e2814bd78c675319846daa63b9e5a Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:47:58 +0200 Subject: [PATCH 2/3] feat(accounts): X follow, unfollow, post lookup and recent search --- src/fopost/__init__.py | 6 +++ src/fopost/resources/accounts.py | 46 +++++++++++++++++++++ tests/test_accounts.py | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 3f5184e..1804055 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -246,6 +246,9 @@ WhatsappSandboxSession, WhatsappTemplate, Workspace, + XFollowResult, + XPost, + XSearchResult, YouTubeCaptionTrack, YouTubePlaylist, YouTubeTranscript, @@ -426,6 +429,9 @@ "AdIdentity", "SparkPost", "SubredditCheck", + "XFollowResult", + "XPost", + "XSearchResult", "TargetingOption", "TelegramBotCommand", "TelegramBotCommands", diff --git a/src/fopost/resources/accounts.py b/src/fopost/resources/accounts.py index 730a4d2..599f013 100644 --- a/src/fopost/resources/accounts.py +++ b/src/fopost/resources/accounts.py @@ -49,6 +49,9 @@ TikTokPlace, TikTokVideoSource, WebhookSubscription, + XFollowResult, + XPost, + XSearchResult, YouTubeCaptionTrack, YouTubePlaylist, YouTubeTranscript, @@ -195,6 +198,49 @@ def set_reddit_default_subreddit( ) ) + def follow_x_user(self, account_id: str, user_id: str) -> XFollowResult: + """Follow a numeric X user id from the account; a pre-upgrade token needs a reconnect.""" + return XFollowResult.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/x/follow", {"user_id": user_id})) + ) + + def unfollow_x_user(self, account_id: str, user_id: str) -> XFollowResult: + return XFollowResult.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/x/unfollow", {"user_id": user_id})) + ) + + def get_x_post(self, post_id: str, account_id: str) -> XPost: + """One X post by id, read with the account's token.""" + return XPost.model_validate( + unwrap( + self._http.get(f"/x/posts/{quote(post_id, safe='')}", {"account_id": account_id}) + ) + ) + + def search_x_posts( + self, + q: str, + account_id: str, + *, + max_results: int | None = None, + next_token: str | None = None, + ) -> XSearchResult: + """Recent X posts matching ``q`` (up to 512 characters); ``max_results`` is 10 to 100.""" + body = self._http.get( + "/x/search", + { + "q": q, + "account_id": account_id, + "max_results": max_results, + "next_token": next_token, + }, + ) + meta = body.get("meta") if isinstance(body, dict) else None + return XSearchResult( + posts=parse_list(XPost, unwrap(body)), + next_token=meta.get("next_token") if isinstance(meta, dict) else None, + ) + def list_slack_channels(self, account_id: str) -> builtins.list[SlackChannel]: """Channels the Slack app can post to in the connected workspace.""" return parse_list( diff --git a/tests/test_accounts.py b/tests/test_accounts.py index ab7cb29..9616d4e 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -531,3 +531,71 @@ def test_tiktok_video_lookup_returns_the_address_a_repurpose_run_reads(client: F ) assert video.video_id == "7300000000000000000" assert video.download_url is not None + + +@respx.mock +def test_x_follow_and_unfollow_send_the_user_id(client: Fopost) -> None: + follow = respx.post(f"{BASE_URL}/accounts/acc_1/x/follow").mock( + return_value=httpx.Response( + 200, json={"data": {"account_id": "acc_1", "user_id": "12345", "following": True}} + ) + ) + unfollow = respx.post(f"{BASE_URL}/accounts/acc_1/x/unfollow").mock( + return_value=httpx.Response( + 200, json={"data": {"account_id": "acc_1", "user_id": "12345", "following": False}} + ) + ) + + result = client.accounts.follow_x_user("acc_1", "12345") + assert result.following is True + assert result.user_id == "12345" + assert json.loads(follow.calls.last.request.content) == {"user_id": "12345"} + + assert client.accounts.unfollow_x_user("acc_1", "12345").following is False + assert json.loads(unfollow.calls.last.request.content) == {"user_id": "12345"} + + +@respx.mock +def test_x_post_lookup_and_search_pass_the_account_through(client: Fopost) -> None: + post = { + "id": "1700000000000000000", + "text": "Shipping day", + "permalink": "https://x.com/yourbrand/status/1700000000000000000", + "author_name": "Your Brand", + "author_handle": "yourbrand", + "author_followers": 1200, + "conversation_id": "1700000000000000000", + "parent_external_id": None, + "likes": 10, + "replies": 2, + "reposts": 3, + "quotes": 0, + "platform_created_at": "2026-10-18T09:00:00.000Z", + } + lookup = respx.get(f"{BASE_URL}/x/posts/1700000000000000000").mock( + return_value=httpx.Response(200, json={"data": post}) + ) + search = respx.get(f"{BASE_URL}/x/search").mock( + return_value=httpx.Response(200, json={"data": [post], "meta": {"next_token": "n2"}}) + ) + + found = client.accounts.get_x_post("1700000000000000000", "acc_1") + assert found.author_handle == "yourbrand" + assert found.reposts == 3 + assert found.platform_created_at is not None + assert lookup.calls.last.request.url.params["account_id"] == "acc_1" + + page = client.accounts.search_x_posts("shipping", "acc_1", max_results=50, next_token="n1") + assert page.posts[0].id == "1700000000000000000" + assert page.next_token == "n2" + params = search.calls.last.request.url.params + assert params["q"] == "shipping" + assert params["account_id"] == "acc_1" + assert params["max_results"] == "50" + assert params["next_token"] == "n1" + + search.mock(return_value=httpx.Response(200, json={"data": [], "meta": {"next_token": None}})) + last = client.accounts.search_x_posts("shipping", "acc_1") + assert last.posts == [] + assert last.next_token is None + assert "max_results" not in search.calls.last.request.url.params From bfde07557fdc237918ccee77021e3ccb22d58c6d Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:47:58 +0200 Subject: [PATCH 3/3] docs: X engagement actions in README and CHANGELOG --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e7349..3800a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to `fopost` are documented here. +## Unreleased + +### Added + +- X engagement: `inbox.repost`, `inbox.unrepost`, `inbox.bookmark` and `inbox.unbookmark` + on X items (where `can_repost` / `can_bookmark` are true), `accounts.follow_x_user` and + `accounts.unfollow_x_user` taking a numeric X user id and returning an `XFollowResult`, + and the pass-through reads `accounts.get_x_post(post_id, account_id)` returning an `XPost` + and `accounts.search_x_posts(q, account_id, max_results=None, next_token=None)` returning + an `XSearchResult` with `posts` and `next_token`. The inbox item gained `reposted`, + `bookmarked`, `can_repost` and `can_bookmark`. Actions and follows need the `inbox` and + `publish` scopes, reads `inbox`; a spent X balance raises `PaymentRequiredError` + (`platform_balance_spent`). Existing X accounts must be reconnected first, since the grant + now asks for the bookmark and follow permissions and an older token answers a 409 + `reconnect_required`. Every one of these calls is an X API call metered to the workspace. + ## 0.3.0 - 2026-10-18 ### Removed diff --git a/README.md b/README.md index 433739f..941e965 100644 --- a/README.md +++ b/README.md @@ -313,14 +313,14 @@ except FopostError as err: | Namespace | Methods | | ------------ | -------------------------------------------------------------------------------------- | | `posts` | `list`, `iter`, `iter_pages`, `get`, `create`, `update`, `delete`, `publish`, `cancel`, `retry`, `preflight`, `deliveries` | -| `accounts` | `list`, `get`, `health`, `update`, `move`, `create_telegram_connect_code`, `get_telegram_connect_status`, `get_telegram_bot_commands`, `set_telegram_bot_commands`, `delete_telegram_bot_commands`, `list_slack_channels`, `list_slack_members`, `get_slack_identity`, `update_slack_identity`, `list_reddit_subreddits`, `list_reddit_subreddit_rules`, `list_reddit_flairs`, `set_reddit_default_subreddit`, `get_ice_breakers`, `set_ice_breakers`, `delete_ice_breakers`, `get_persistent_menu`, `set_persistent_menu`, `delete_persistent_menu`, `get_greeting`, `set_greeting`, `delete_greeting`, `get_webhook_subscription`, `resubscribe_webhook`, `list_discord_channels`, `switch_discord_channel`, `get_discord_identity`, `update_discord_identity`, `list_discord_pins`, `delete_discord_message`, `pin_discord_message`, `unpin_discord_message`, `crosspost_discord_message`, `create_discord_thread`, `send_discord_dm`, `list_discord_events`, `get_discord_event`, `create_discord_event`, `update_discord_event`, `delete_discord_event`, `list_discord_members`, `get_discord_member`, `list_discord_roles`, `create_discord_role`, `update_discord_role`, `delete_discord_role`, `add_discord_member_role`, `remove_discord_member_role`, `platform_metrics` | +| `accounts` | `list`, `get`, `health`, `update`, `move`, `create_telegram_connect_code`, `get_telegram_connect_status`, `get_telegram_bot_commands`, `set_telegram_bot_commands`, `delete_telegram_bot_commands`, `list_slack_channels`, `list_slack_members`, `get_slack_identity`, `update_slack_identity`, `list_reddit_subreddits`, `list_reddit_subreddit_rules`, `list_reddit_flairs`, `set_reddit_default_subreddit`, `follow_x_user`, `unfollow_x_user`, `get_x_post`, `search_x_posts`, `get_ice_breakers`, `set_ice_breakers`, `delete_ice_breakers`, `get_persistent_menu`, `set_persistent_menu`, `delete_persistent_menu`, `get_greeting`, `set_greeting`, `delete_greeting`, `get_webhook_subscription`, `resubscribe_webhook`, `list_discord_channels`, `switch_discord_channel`, `get_discord_identity`, `update_discord_identity`, `list_discord_pins`, `delete_discord_message`, `pin_discord_message`, `unpin_discord_message`, `crosspost_discord_message`, `create_discord_thread`, `send_discord_dm`, `list_discord_events`, `get_discord_event`, `create_discord_event`, `update_discord_event`, `delete_discord_event`, `list_discord_members`, `get_discord_member`, `list_discord_roles`, `create_discord_role`, `update_discord_role`, `delete_discord_role`, `add_discord_member_role`, `remove_discord_member_role`, `platform_metrics` | | `account_groups` | `list`, `create`, `get`, `update`, `delete`, `set_members` | | `workspaces` | `list`, `get` | | `labels` | `list` | | `media` | `limits`, `presign`, `complete`, `upload_direct` | | `ai` | `credits`, `generate_caption`, `rewrite`, `repurpose_url` | -| `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `vote`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `handover`, `list_approvals`, `approve_reply`, `reject_reply` | -| `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `vote`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `list_approvals`, `approve_reply`, `reject_reply` | +| `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `vote`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `handover`, `list_approvals`, `approve_reply`, `reject_reply` | +| `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `vote`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `list_approvals`, `approve_reply`, `reject_reply` | | `contacts` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `import_csv`, `list_fields`, `create_field`, `update_field`, `delete_field`, `conversation_analytics` | | `broadcasts` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` | | `sequences` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` |