diff --git a/README.md b/README.md index 3ebc6c2..dda7888 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`, `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`, `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`, `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`, `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`, `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` | | `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` | @@ -329,7 +329,7 @@ except FopostError as err: | `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` | | `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` | | `blog` | `get_settings`, `update_settings`, `rotate_website_secret`, `test_website`, `list_keywords`, `suggest_seeds`, `plan_keywords`, `add_keyword`, `update_keyword`, `delete_keyword`, `list_articles`, `request_article`, `get_article`, `update_article`, `regenerate_article`, `submit_article`, `publish_article`, `reject_article`, `delete_article` | -| `validate` | `post`, `length`, `media` | +| `validate` | `post`, `length`, `media`, `subreddit` | | `whatsapp` | `get_profile`, `update_profile`, `request_display_name`, `set_username`, `list_templates`, `list_template_library`, `get_template`, `create_template`, `import_template`, `update_template`, `delete_template`, `list_groups`, `create_group`, `get_group`, `update_group`, `delete_group`, `get_group_invite_link`, `reset_group_invite_link`, `remove_group_participants`, `list_blocked`, `block_users`, `unblock_users`, `get_commerce_settings`, `update_commerce_settings`, `link_catalog`, `list_flows`, `get_flow`, `create_flow`, `update_flow`, `delete_flow`, `upload_flow_json`, `publish_flow`, `deprecate_flow`, `list_flow_responses`, `get_encryption_key_status`, `set_encryption_key`, `get_account_events`, `list_sandbox_sessions`, `create_sandbox_session` | | `analytics` | `decay`, `frequency`, `timeline`, `changes`, `collect_post`, `native_posts` | | `activity` | `list` | diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 6936c48..2734221 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -192,6 +192,12 @@ PostTimeline, PresignedUpload, ReachEstimate, + RedditDefaultSubreddit, + RedditFlair, + RedditFlairs, + RedditSubreddit, + RedditSubredditRule, + RedditSubredditRules, RepurposeResult, RewriteResult, RewriteVariant, @@ -203,6 +209,7 @@ SlackMember, SocialAccount, SparkPost, + SubredditCheck, SupplyForecast, TargetingOption, TelegramBotCommand, @@ -353,6 +360,12 @@ "UploadLimitTier", "UploadLimits", "ReachEstimate", + "RedditDefaultSubreddit", + "RedditFlair", + "RedditFlairs", + "RedditSubreddit", + "RedditSubredditRule", + "RedditSubredditRules", "RepurposeResult", "RewriteResult", "RewriteVariant", @@ -406,6 +419,7 @@ "AdCommentsPage", "AdIdentity", "SparkPost", + "SubredditCheck", "TargetingOption", "TelegramBotCommand", "TelegramBotCommands", diff --git a/src/fopost/models.py b/src/fopost/models.py index f72b0f4..2636f17 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -102,6 +102,12 @@ "UploadLimitTier", "UploadLimits", "ReachEstimate", + "RedditDefaultSubreddit", + "RedditFlair", + "RedditFlairs", + "RedditSubreddit", + "RedditSubredditRule", + "RedditSubredditRules", "RepurposeResult", "RewriteResult", "RewriteVariant", @@ -116,6 +122,7 @@ "SlackIdentity", "SlackMember", "SocialAccount", + "SubredditCheck", "TargetingOption", "TelegramBotCommand", "TelegramBotCommands", @@ -424,6 +431,7 @@ class SocialAccount(FopostModel): health_status: str | None = None last_health_check: datetime | None = None platform_name: str | None = None + reconnect_required: bool | None = None class AccountRename(FopostModel): @@ -814,6 +822,60 @@ class AccountPlatformMetrics(FopostModel): post: PlatformMetricsBlock +class RedditSubreddit(FopostModel): + """A subreddit the account is in, or its own profile page.""" + + name: str + title: str | None = None + subscribers: int | None = None + over18: bool = False + can_post: bool = True + flair_enabled: bool = False + icon_url: str | None = None + is_default: bool = False + + +class RedditSubredditRule(FopostModel): + name: str + description: str | None = None + applies_to: str | None = None + + +class RedditSubredditRules(FopostModel): + subreddit: str + rules: list[RedditSubredditRule] = [] + + +class RedditFlair(FopostModel): + """``editable`` means the label may be replaced with your own text.""" + + id: str + text: str = "" + editable: bool = False + + +class RedditFlairs(FopostModel): + subreddit: str + flairs: list[RedditFlair] = [] + + +class RedditDefaultSubreddit(FopostModel): + """``None`` means the account's own profile page.""" + + subreddit: str | None = None + + +class SubredditCheck(FopostModel): + """``ok`` is true when the subreddit exists and takes a post from this account.""" + + subreddit: str + exists: bool = False + can_post: bool = False + over_18: bool = False + flair_enabled: bool = False + ok: bool = False + + class Workspace(FopostModel): id: str name: str @@ -1156,10 +1218,12 @@ class InboxItem(FopostModel): can_hide: bool | None = None can_delete: bool | None = None liked: bool | None = None + vote: str | None = None pinned: bool | None = None reaction: str | None = None edited_at: datetime | None = None can_like: bool | None = None + can_vote: bool | None = None can_pin: bool | None = None can_edit: bool | None = None can_react: bool | None = None diff --git a/src/fopost/resources/accounts.py b/src/fopost/resources/accounts.py index 141ef56..730a4d2 100644 --- a/src/fopost/resources/accounts.py +++ b/src/fopost/resources/accounts.py @@ -5,6 +5,7 @@ import builtins from collections.abc import Mapping, Sequence from typing import Any +from urllib.parse import quote from .._http import unwrap from ..models import ( @@ -31,6 +32,10 @@ MetaPersistentMenu, MetaPersistentMenuEntry, PinterestBoard, + RedditDefaultSubreddit, + RedditFlairs, + RedditSubreddit, + RedditSubredditRules, SlackChannel, SlackIdentity, SlackMember, @@ -154,6 +159,42 @@ def delete_telegram_bot_commands(self, account_id: str) -> TelegramBotCommands: unwrap(self._http.delete(f"/accounts/{account_id}/telegram/commands")) ) + def list_reddit_subreddits(self, account_id: str) -> builtins.list[RedditSubreddit]: + """Subreddits the account is in, busiest first, plus its own profile page.""" + return parse_list( + RedditSubreddit, unwrap(self._http.get(f"/accounts/{account_id}/reddit/subreddits")) + ) + + def list_reddit_subreddit_rules(self, account_id: str, subreddit: str) -> RedditSubredditRules: + """The rules a subreddit publishes, in its own order.""" + return RedditSubredditRules.model_validate( + unwrap( + self._http.get( + f"/accounts/{account_id}/reddit/subreddits/{quote(subreddit, safe='')}/rules" + ) + ) + ) + + def list_reddit_flairs(self, account_id: str, subreddit: str) -> RedditFlairs: + """Post flairs one subreddit offers; a flair id is valid only there.""" + return RedditFlairs.model_validate( + unwrap( + self._http.get(f"/accounts/{account_id}/reddit/flairs", {"subreddit": subreddit}) + ) + ) + + def set_reddit_default_subreddit( + self, account_id: str, subreddit: str | None + ) -> RedditDefaultSubreddit: + """Where posts go when a post names none; ``None`` falls back to the profile page.""" + return RedditDefaultSubreddit.model_validate( + unwrap( + self._http.put( + f"/accounts/{account_id}/reddit/default-subreddit", {"subreddit": subreddit} + ) + ) + ) + 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/src/fopost/resources/inbox.py b/src/fopost/resources/inbox.py index 9cf9af8..0cf0643 100644 --- a/src/fopost/resources/inbox.py +++ b/src/fopost/resources/inbox.py @@ -228,6 +228,16 @@ 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 vote(self, item_id: str, direction: str) -> InboxItem: + """Vote up or down where the network ranks by votes, where ``can_vote`` is true. + + ``direction`` is ``"up"``, ``"down"``, or ``"none"`` to take an earlier vote + back. An upvote is the same call a like makes, so ``liked`` moves with it. + """ + return InboxItem.model_validate( + unwrap(self._http.post(f"/inbox/{item_id}/vote", {"direction": direction})) + ) + def pin(self, item_id: str) -> InboxItem: """Pin our own comment, where ``can_pin`` is true.""" return InboxItem.model_validate(unwrap(self._http.post(f"/inbox/{item_id}/pin"))) diff --git a/src/fopost/resources/validate.py b/src/fopost/resources/validate.py index a1c4fd2..968aa99 100644 --- a/src/fopost/resources/validate.py +++ b/src/fopost/resources/validate.py @@ -9,7 +9,12 @@ from typing import Any from .._http import unwrap -from ..models import ValidateLengthResult, ValidateMediaResult, ValidatePostResult +from ..models import ( + SubredditCheck, + ValidateLengthResult, + ValidateMediaResult, + ValidatePostResult, +) from ._base import Resource __all__ = ["ValidateResource"] @@ -46,3 +51,12 @@ def media(self, *, url: str) -> ValidateMediaResult: return ValidateMediaResult.model_validate( unwrap(self._http.post("/validate/media", {"url": url})) ) + + def subreddit(self, *, account_id: str, name: str) -> SubredditCheck: + """Whether a subreddit exists and takes a post from this Reddit account. + + The check runs with the account's own token, so ``account_id`` is required. + """ + return SubredditCheck.model_validate( + unwrap(self._http.get("/validate/subreddit", {"account_id": account_id, "name": name})) + ) diff --git a/tests/test_reddit.py b/tests/test_reddit.py new file mode 100644 index 0000000..4800624 --- /dev/null +++ b/tests/test_reddit.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import httpx +import pytest +import respx + +from fopost import Fopost, FopostError + +from .conftest import BASE_URL + + +@respx.mock +def test_subreddits_rules_and_flairs(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/reddit/subreddits").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "name": "webdev", + "title": "Web Development", + "subscribers": 2_000_000, + "over18": False, + "canPost": True, + "flairEnabled": True, + "iconUrl": None, + "isDefault": True, + } + ] + }, + ) + ) + respx.get(f"{BASE_URL}/accounts/acc_1/reddit/subreddits/webdev/rules").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "subreddit": "webdev", + "rules": [ + { + "name": "No self promotion", + "description": "Keep it useful", + "appliesTo": "link", + } + ], + } + }, + ) + ) + flairs = respx.get(f"{BASE_URL}/accounts/acc_1/reddit/flairs").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "subreddit": "webdev", + "flairs": [{"id": "flair-1", "text": "Showoff Saturday", "editable": False}], + } + }, + ) + ) + + subreddits = client.accounts.list_reddit_subreddits("acc_1") + assert subreddits[0].name == "webdev" + assert subreddits[0].is_default is True + assert subreddits[0].flair_enabled is True + + rules = client.accounts.list_reddit_subreddit_rules("acc_1", "webdev") + assert rules.rules[0].applies_to == "link" + + result = client.accounts.list_reddit_flairs("acc_1", "webdev") + assert result.flairs[0].id == "flair-1" + assert flairs.calls.last.request.url.params["subreddit"] == "webdev" + + +@respx.mock +def test_default_subreddit_accepts_none(client: Fopost) -> None: + route = respx.put(f"{BASE_URL}/accounts/acc_1/reddit/default-subreddit").mock( + return_value=httpx.Response(200, json={"data": {"subreddit": None}}) + ) + + assert client.accounts.set_reddit_default_subreddit("acc_1", None).subreddit is None + assert route.calls.last.request.content == b'{"subreddit":null}' + + +@respx.mock +def test_validate_subreddit(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/validate/subreddit").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "subreddit": "webdev", + "exists": True, + "can_post": True, + "over_18": False, + "flair_enabled": True, + "ok": True, + } + }, + ) + ) + + check = client.validate.subreddit(account_id="acc_1", name="webdev") + assert check.ok is True + assert route.calls.last.request.url.params["account_id"] == "acc_1" + + +@respx.mock +def test_vote_sends_the_direction(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/inbox/item_1/vote").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "id": "item_1", + "platform": "reddit", + "type": "comment", + "state": "unread", + "vote": "down", + "canVote": True, + } + }, + ) + ) + + item = client.inbox.vote("item_1", "down") + assert item.vote == "down" + assert item.can_vote is True + assert route.calls.last.request.content == b'{"direction":"down"}' + + +@respx.mock +def test_a_stale_grant_is_a_409(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/reddit/subreddits").mock( + return_value=httpx.Response( + 409, json={"error": "reconnect_required", "message": "Reconnect this account"} + ) + ) + + with pytest.raises(FopostError) as err: + client.accounts.list_reddit_subreddits("acc_1") + assert err.value.status == 409