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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to `fopost` are documented here.

### 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.
- Ads tracking (`ads` scope): `ads.send_conversions` is the Conversions API send with a
required `idempotency_key` sent as the `Idempotency-Key` header (a reused key answers
`replayed=True`), `ads.conversion_batches` lists stored batches with hashed identifiers,
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,14 +313,14 @@ except FopostError as err:
| Namespace | Methods |
| ------------ | -------------------------------------------------------------------------------------- |
| `posts` | `list`, `iter`, `iter_pages`, `get`, `create`, `update`, `delete`, `publish`, `cancel`, `retry`, `preflight`, `unpublish`, `deliveries`, `validate_bulk_import`, `commit_bulk_import`, `rollback_bulk_import` |
| `accounts` | `list`, `get`, `health`, `update`, `move`, `toggle_primary`, `validate_credentials`, `refresh_token`, `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_communities`, `sync_communities`, `search_communities`, `add_community`, `remove_community`, `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`, `toggle_primary`, `validate_credentials`, `refresh_token`, `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_communities`, `sync_communities`, `search_communities`, `add_community`, `remove_community`, `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`, `create`, `update`, `delete` |
| `labels` | `list`, `create`, `get`, `update`, `delete` |
| `media` | `list`, `upload`, `delete`, `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` |
Expand Down
6 changes: 6 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@
WhatsappSandboxSession,
WhatsappTemplate,
Workspace,
XFollowResult,
XPost,
XSearchResult,
YouTubeCaptionTrack,
YouTubePlaylist,
YouTubeTranscript,
Expand Down Expand Up @@ -523,6 +526,9 @@
"AdIdentity",
"SparkPost",
"SubredditCheck",
"XFollowResult",
"XPost",
"XSearchResult",
"TargetingOption",
"TelegramBotCommand",
"TelegramBotCommands",
Expand Down
41 changes: 41 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@
"SlackMember",
"SocialAccount",
"SubredditCheck",
"XFollowResult",
"XPost",
"XSearchResult",
"TargetingOption",
"TelegramBotCommand",
"TelegramBotCommands",
Expand Down Expand Up @@ -969,6 +972,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
Expand Down Expand Up @@ -1317,8 +1354,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
Expand Down
46 changes: 46 additions & 0 deletions src/fopost/resources/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
TikTokPlace,
TikTokVideoSource,
WebhookSubscription,
XFollowResult,
XPost,
XSearchResult,
YouTubeCaptionTrack,
YouTubePlaylist,
YouTubeTranscript,
Expand Down Expand Up @@ -218,6 +221,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(
Expand Down
14 changes: 14 additions & 0 deletions src/fopost/resources/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
68 changes: 68 additions & 0 deletions tests/test_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,71 @@ def test_primary_validate_and_refresh_token_post_to_the_account(client: Fopost)

assert client.accounts.refresh_token("acc_1").expires_at is not None
assert refresh.called


@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
28 changes: 28 additions & 0 deletions tests/test_inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading