diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e7349..bc7bb73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to `fopost` are documented here. +## 0.4.0 - 2026-10-18 + +### Added + +- `client.queue`: `list_slots`, `create_slot`, `update_slot`, `delete_slot`, `next` and + `preview` for the posting queue, and `schedule="queue"` on `posts.create` and `posts.update` + to take the next free slot instead of a `schedule_at`. +- `client.connect_sessions`: `create` and `get` for hosted connect links (`ConnectSession`). +- `client.webhook_endpoints`: `list`, `create`, `update`, `delete`, `test`, `deliveries`, + `get_delivery` and `redeliver` (`WebhookEndpoint`, `WebhookDelivery`, `WEBHOOK_EVENTS`). The + name is deliberately not `webhooks`, which stays the signature-verification module. +- `client.automations`: `list`, `create`, `get`, `update`, `delete`, `toggle`, `stats`, + `versions`, `get_version`, `restore_version`, `runs` and `get_run` (`Automation`, + `AutomationVersion`, `AutomationRun`, `AutomationStats`, `AUTOMATION_TRIGGER_TYPES`). +- `client.logs.list` for the request log, with `meta.next_cursor` and `summary` + (`RequestLogPage`). +- `posts.unpublish` (`UnpublishResult`) and bulk import: `posts.validate_bulk_import`, + `posts.commit_bulk_import`, `posts.rollback_bulk_import`. +- `workspaces.create`, `workspaces.update`, `workspaces.delete`; `labels.create`, `labels.get`, + `labels.update`, `labels.delete`. +- `accounts.toggle_primary`, `accounts.validate_credentials` and `accounts.refresh_token` + (`AccountPrimary`, `AccountValidation`, `AccountTokenRefresh`). +- `accounts.list_communities`, `sync_communities`, `search_communities`, `add_community` and + `remove_community` for X communities (`Community`, `CommunitySearchResult`). +- `media.list`, `media.upload` (multipart, up to 5 files) and `media.delete`; `MediaItem` carries + the library-row fields (`workspace_id`, `mime_type`, `tags`, `folder_id`, `brand_role`, …). +- `analytics.overview`, `time_series`, `top_posts`, `labels`, `posts_table`, `posting_streak`, + `demographics`, `inbox` and `collect`, typed as `AnalyticsOverview`, `AnalyticsTimeSeries`, + `TopPost`, `LabelAnalytics`, `PostsTable`, `PostingStreakDay`, `AudienceDemographics`, + `InboxAnalytics` and `AnalyticsCollectResult`. +- `Workspace.connect_redirect_domains` and `updated_at`; `Label.created_at` and `updated_at`. + ## 0.3.0 - 2026-10-18 ### Removed diff --git a/CLAUDE.md b/CLAUDE.md index 009efe0..71d5e41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,8 @@ Guidance for Claude Code (claude.ai/code) when working in this repository. ## What This Is `fopost` on PyPI — the official Python client for the FoPost REST API (`fopost.com`). -Current version `0.3.0`. It wraps the API's HTTP surface in a namespaced client -(`posts`, `accounts`, `account_groups`, `workspaces`, `labels`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`) returning pydantic v2 models. +Current version `0.4.0`. It wraps the API's HTTP surface in a namespaced client +(`posts`, `accounts`, `account_groups`, `workspaces`, `labels`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `queue`, `connect_sessions`, `webhook_endpoints`, `automations`, `logs`, …) returning pydantic v2 models. Requires Python >= 3.10 (CI matrix: 3.10–3.13). Runtime deps: `httpx>=0.27`, `pydantic>=2.7`. Built with hatchling from `src/fopost`, ships `py.typed`. @@ -48,7 +48,8 @@ src/fopost/ chat_adapter.py ChatAdapter — the inbox as a send/receive interface, imported on its own resources/ _base.py (Resource, parse_list, UNSET, drop_unset) posts.py accounts.py account_groups.py workspaces.py labels.py ai.py - inbox.py contacts.py broadcasts.py ads.py + inbox.py contacts.py broadcasts.py ads.py media.py analytics.py + queue.py connect_sessions.py webhook_endpoints.py automations.py logs.py ``` Request flow: a resource method builds a snake_case body/params dict, calls @@ -110,13 +111,21 @@ TypeScript SDK. - Escape hatch: `client.request(method, path, json=..., params=...)` returns the decoded body with no unwrap and no model parsing. -Resource coverage is a subset of the API: posts (list/iter/iter_pages/get/create/update/ -delete/publish/cancel/retry/preflight/deliveries), accounts (list/get/health/update/move, Telegram connect code and bot commands, Slack channels, members and posting identity), -account_groups (list/create/get/update/delete/set_members), workspaces -(list/get), labels (list), ai (credits/generate_caption/rewrite/repurpose_url), inbox (the `/v1/inbox` -family except the X Chat routes), ads (the `/v1/ads` family), validate (post/length/media). `communities`, `webhooks`, -`analytics`, `automations`, and `media` are **not wrapped here** — the Go and Rust SDKs have -them. Adding one is a public-surface change: see the Downstream Packages rule. +Resource coverage: posts (list/iter/iter_pages/get/create/update/delete/publish/cancel/retry/ +preflight/unpublish/deliveries, bulk import validate/commit/rollback, `schedule="queue"` on +create and update), accounts (list/get/health/update/move, X communities, Telegram connect code +and bot commands, Slack channels, members and posting identity, …), 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 (the `/v1/inbox` family except the X +Chat routes), ads (the `/v1/ads` family), validate (post/length/media), analytics (dashboard +reads plus decay/frequency/timeline/changes), queue (slots CRUD, next, preview), connect_sessions +(create/get), webhook_endpoints (CRUD, test, deliveries, redeliver), automations (CRUD, toggle, +stats, versions, runs), logs (list). **`client.webhook_endpoints` manages outbound endpoints; +`fopost.webhooks` (the module) verifies the deliveries they receive** — the names differ on +purpose so `from fopost import webhooks` keeps meaning the verifier. The checker in +`fopost-api-collections` (`scripts/sdk-gaps.mjs`) must report no gap before a release. Adding a +resource is a public-surface change: see the Downstream Packages rule. ## Commands diff --git a/README.md b/README.md index 433739f..8c6cfe1 100644 --- a/README.md +++ b/README.md @@ -312,12 +312,12 @@ 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` | +| `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` | | `account_groups` | `list`, `create`, `get`, `update`, `delete`, `set_members` | -| `workspaces` | `list`, `get` | -| `labels` | `list` | -| `media` | `limits`, `presign`, `complete`, `upload_direct` | +| `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` | @@ -332,8 +332,13 @@ except FopostError as err: | `blogs` | `list_blogs`, `list_articles`, `get_article`, `create_article`, `update_article`, `delete_article`, `list_products`, `update_product` | | `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` | +| `analytics` | `overview`, `time_series`, `top_posts`, `labels`, `posts_table`, `posting_streak`, `demographics`, `inbox`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collect_post`, `native_posts` | | `activity` | `list` | +| `queue` | `list_slots`, `create_slot`, `update_slot`, `delete_slot`, `next`, `preview` | +| `connect_sessions` | `create`, `get` | +| `webhook_endpoints` | `list`, `create`, `update`, `delete`, `test`, `deliveries`, `get_delivery`, `redeliver` | +| `automations` | `list`, `create`, `get`, `update`, `delete`, `toggle`, `stats`, `versions`, `get_version`, `restore_version`, `runs`, `get_run` | +| `logs` | `list` | For an endpoint the SDK does not wrap yet, `client.request` sends an authenticated call and hands back the decoded body: diff --git a/pyproject.toml b/pyproject.toml index bb054c7..5b6faef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "fopost" -version = "0.3.0" +version = "0.4.0" description = "Official Python SDK for the FoPost API. Schedule and publish to +30 social platforms from your code." readme = "README.md" license = "MIT" diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 3f5184e..a9c24a5 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -31,16 +31,22 @@ RateLimitError, ) from .models import ( + AUTOMATION_TRIGGER_TYPES, BLOG_ARTICLE_STATUSES, BLOG_GOALS, BLOG_KEYWORD_STATUSES, BLOG_TARGET_KINDS, + CONNECT_SESSION_STATUSES, PLATFORMS, POST_STATUSES, + WEBHOOK_EVENTS, AccountGroup, AccountMove, AccountPlatformMetrics, + AccountPrimary, AccountRename, + AccountTokenRefresh, + AccountValidation, ActivityActor, ActivityEvent, ActivityPage, @@ -63,10 +69,36 @@ AdSource, AiCreditBalance, AiCredits, + AnalyticsAccountSummary, + AnalyticsCollectError, + AnalyticsCollectResult, + AnalyticsDeltas, + AnalyticsOverview, + AnalyticsPlatformTotals, + AnalyticsTimeSeries, + AnalyticsTodayStats, ArticlePublishedPayload, Audience, + AudienceDemographics, AudienceFilter, AudiencesResult, + Automation, + AutomationEdge, + AutomationGraph, + AutomationNode, + AutomationNodeLayout, + AutomationPageMeta, + AutomationRestoredVersion, + AutomationRun, + AutomationRunLog, + AutomationRunSummary, + AutomationScheduleWindow, + AutomationStats, + AutomationStep, + AutomationToggle, + AutomationVersion, + AutomationVersionAuthor, + AutomationVersionNode, BidPricing, BlogArticle, BlogArticleFull, @@ -86,9 +118,18 @@ BroadcastCounts, BroadcastRecipient, BulkAdStatusResult, + BulkImportCreatedPost, + BulkImportResult, + BulkImportRollback, + BulkImportRow, + BulkImportValidation, CaptionResult, CollectPostDelivery, CollectPostResult, + Community, + CommunitySearchResult, + ConnectSession, + ConnectSessionBranding, Contact, ContactChannel, ContactConversation, @@ -105,6 +146,9 @@ ConversionRule, DecayBand, Delivery, + DemographicAccountRef, + DemographicBucket, + DemographicDimensions, DiscordChannel, DiscordIdentity, DiscordMember, @@ -115,6 +159,7 @@ Enrollment, ExternalAd, FeedLead, + FollowerHistoryPoint, FrequencyBand, FrequencyBest, FrequencyWeek, @@ -136,15 +181,24 @@ GoogleSharedSet, InboxAccount, InboxAccountRef, + InboxAnalytics, + InboxAnalyticsAccount, + InboxAnalyticsDeltas, + InboxAnalyticsPlatform, + InboxAnalyticsPoint, + InboxAnalyticsTotals, + InboxAnalyticsType, InboxApproval, InboxAttachment, InboxConversation, InboxHandover, + InboxHeatmapCell, InboxItem, InboxPlatform, InboxPostContext, InboxRefreshResult, InboxReplyResult, + InboxResponseTimeBucket, InboxStartConversationResult, InboxThread, InsightsMetrics, @@ -157,6 +211,7 @@ KnowledgeSource, KnowledgeSyncResult, Label, + LabelAnalytics, Lead, LeadForm, LeadFormDetail, @@ -188,9 +243,20 @@ Post, PostAccount, PostingFrequency, + PostingStreakDay, + PostsTable, + PostsTableDeliverySummary, + PostsTablePlatform, + PostsTableRow, + PostsTableSummary, PostStatus, PostTimeline, PresignedUpload, + QueueMovedPost, + QueueNext, + QueueSlot, + QueueSlotDeletion, + QueueSlotUpdate, ReachEstimate, RedditDefaultSubreddit, RedditFlair, @@ -202,6 +268,11 @@ RemoteBlog, RemoteProduct, RepurposeResult, + RequestLog, + RequestLogBucket, + RequestLogMeta, + RequestLogPage, + RequestLogSummary, RewriteResult, RewriteVariant, Sequence, @@ -226,6 +297,13 @@ TimelineDelivery, TimelineDelta, TimelinePoint, + TimeSeriesPoint, + TopPost, + TopPostLabel, + TopPostMetrics, + TopPostPlatform, + UnpublishDelivery, + UnpublishResult, UploadLimits, UploadLimitTier, ValidateLengthCheck, @@ -233,6 +311,8 @@ ValidateMediaResult, ValidatePlatformCheck, ValidatePostResult, + WebhookDelivery, + WebhookEndpoint, WebhookSubscription, WhatsappBlockResult, WhatsappCommerceSettings, @@ -264,6 +344,9 @@ __all__ = [ "AccountGroup", "AccountMove", + "AccountPrimary", + "AccountTokenRefresh", + "AccountValidation", "AccountPlatformMetrics", "AccountRename", "ActivityActor", @@ -512,4 +595,81 @@ "WebhookVerificationError", "verify_webhook", "webhooks", + "AUTOMATION_TRIGGER_TYPES", + "CONNECT_SESSION_STATUSES", + "WEBHOOK_EVENTS", + "AnalyticsAccountSummary", + "AnalyticsCollectError", + "AnalyticsCollectResult", + "AnalyticsDeltas", + "AnalyticsOverview", + "AnalyticsPlatformTotals", + "AnalyticsTimeSeries", + "AnalyticsTodayStats", + "AudienceDemographics", + "Automation", + "AutomationEdge", + "AutomationGraph", + "AutomationNode", + "AutomationNodeLayout", + "AutomationPageMeta", + "AutomationRestoredVersion", + "AutomationRun", + "AutomationRunLog", + "AutomationRunSummary", + "AutomationScheduleWindow", + "AutomationStats", + "AutomationStep", + "AutomationToggle", + "AutomationVersion", + "AutomationVersionAuthor", + "AutomationVersionNode", + "BulkImportCreatedPost", + "BulkImportResult", + "BulkImportRollback", + "BulkImportRow", + "BulkImportValidation", + "Community", + "CommunitySearchResult", + "ConnectSession", + "ConnectSessionBranding", + "DemographicAccountRef", + "DemographicBucket", + "DemographicDimensions", + "FollowerHistoryPoint", + "InboxAnalytics", + "InboxAnalyticsAccount", + "InboxAnalyticsDeltas", + "InboxAnalyticsPlatform", + "InboxAnalyticsPoint", + "InboxAnalyticsTotals", + "InboxAnalyticsType", + "InboxHeatmapCell", + "InboxResponseTimeBucket", + "LabelAnalytics", + "PostingStreakDay", + "PostsTable", + "PostsTableDeliverySummary", + "PostsTablePlatform", + "PostsTableRow", + "PostsTableSummary", + "QueueMovedPost", + "QueueNext", + "QueueSlot", + "QueueSlotDeletion", + "QueueSlotUpdate", + "RequestLog", + "RequestLogBucket", + "RequestLogMeta", + "RequestLogPage", + "RequestLogSummary", + "TimeSeriesPoint", + "TopPost", + "TopPostLabel", + "TopPostMetrics", + "TopPostPlatform", + "UnpublishDelivery", + "UnpublishResult", + "WebhookDelivery", + "WebhookEndpoint", ] diff --git a/src/fopost/client.py b/src/fopost/client.py index 045c555..6ed046f 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -16,18 +16,23 @@ AdsResource, AiResource, AnalyticsResource, + AutomationsResource, BlogResource, BlogsResource, BroadcastsResource, + ConnectSessionsResource, ContactsResource, GoogleBusinessResource, InboxResource, KnowledgeResource, LabelsResource, + LogsResource, MediaResource, PostsResource, + QueueResource, SequencesResource, ValidateResource, + WebhookEndpointsResource, WhatsappResource, WorkspacesResource, ) @@ -93,6 +98,12 @@ def __init__( self.google_business = GoogleBusinessResource(self._http) self.blog = BlogResource(self._http) self.whatsapp = WhatsappResource(self._http) + self.queue = QueueResource(self._http) + self.connect_sessions = ConnectSessionsResource(self._http) + # ``fopost.webhooks`` verifies deliveries; this manages the endpoints. + self.webhook_endpoints = WebhookEndpointsResource(self._http) + self.automations = AutomationsResource(self._http) + self.logs = LogsResource(self._http) @property def base_url(self) -> str: diff --git a/src/fopost/models.py b/src/fopost/models.py index c84d48e..dbb35c6 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -325,6 +325,23 @@ class MediaItem(FopostModel): size: int | None = None alt: str | None = None thumbnail: str | None = None + # Library-row fields, present on ``GET /media`` and the upload routes. + workspace_id: str | None = None + visibility: str | None = None + mime_type: str | None = None + alt_text: str | None = None + alt_text_source: str | None = None + tags: list[str] = [] + folder_id: str | None = None + origin: str | None = None + source_url: str | None = None + synced_at: datetime | None = None + brand_role: str | None = None + brand_voice_id: str | None = None + generation_run_id: str | None = None + has_person_consent: bool | None = None + share: dict[str, Any] | None = None + created_at: datetime | None = None class PresignedUpload(FopostModel): @@ -393,6 +410,8 @@ class Label(FopostModel): name: str color: str | None = None workspace: dict[str, Any] | None = None + created_at: datetime | None = None + updated_at: datetime | None = None class Post(FopostModel): @@ -445,6 +464,29 @@ class AccountRename(FopostModel): platform_name: str | None = None +class AccountPrimary(FopostModel): + id: str + is_primary: bool + + +class AccountValidation(FopostModel): + """Stored credentials checked against the platform; also updates health.""" + + account_id: str + platform: str + valid: bool + health_status: str + username: str | None = None + name: str | None = None + avatar: str | None = None + delivery: str | None = None + message: str | None = None + + +class AccountTokenRefresh(FopostModel): + expires_at: datetime | None = None + + class AccountMove(FopostModel): id: str workspace_id: str @@ -942,7 +984,9 @@ class Workspace(FopostModel): ai_alt_text_enabled: bool | None = None brand_color: str | None = None role: str | None = None + connect_redirect_domains: list[str] = [] created_at: datetime | None = None + updated_at: datetime | None = None accounts: list[SocialAccount] = [] @@ -2734,3 +2778,719 @@ class WhatsappSandboxSession(FopostModel): invited_at: datetime activated_at: datetime | None = None expires_at: datetime + + +# --- Posting queue --------------------------------------------------------- + + +class QueueSlot(FopostModel): + """One recurring weekly posting slot.""" + + id: str + workspace_id: str + account_id: str | None = None + weekday: int + time: str + timezone: str + created_at: datetime | None = None + + +class QueueMovedPost(FopostModel): + """A queued post moved to a new time when its slot changed.""" + + id: str + from_: str + to: str + + model_config = ConfigDict( + alias_generator=AliasGenerator( + validation_alias=lambda name: ( + AliasChoices("from", "from_") if name == "from_" else _aliases(name) + ) + ), + populate_by_name=True, + extra="allow", + ) + + +class QueueSlotUpdate(QueueSlot): + moved: list[QueueMovedPost] = [] + + +class QueueSlotDeletion(FopostModel): + deleted: bool + moved: list[QueueMovedPost] = [] + + +class QueueNext(FopostModel): + next: datetime | None = None + + +# --- Hosted connect links -------------------------------------------------- + +CONNECT_SESSION_STATUSES: tuple[str, ...] = ("pending", "used", "expired") + + +class ConnectSessionBranding(FopostModel): + name: str | None = None + logo_url: str | None = None + + +class ConnectSession(FopostModel): + """A single-use link that lets an end user connect an account.""" + + id: str + status: str + external_user_id: str + platforms: list[str] = [] + redirect_url: str | None = None + branding: ConnectSessionBranding | None = None + account_id: str | None = None + url: str | None = None + expires_at: datetime + used_at: datetime | None = None + created_at: datetime | None = None + + +# --- Outbound webhooks ----------------------------------------------------- + +WEBHOOK_EVENTS: tuple[str, ...] = ( + "post.scheduled", + "post.published", + "post.partially_failed", + "post.failed", + "post.deleted", + "delivery.published", + "delivery.failed", + "delivery.delayed", + "account.connected", + "account.disconnected", + "account.health_changed", + "article.published", + "inbox.comment_received", + "inbox.mention_received", + "inbox.message_received", + "inbox.review_received", + "automation.triggered", + "automation.completed", + "automation.failed", +) + + +class WebhookEndpoint(FopostModel): + """A registered HTTPS endpoint. ``secret`` is only present on create.""" + + id: str + workspace_id: str | None = None + name: str | None = None + url: str + events: list[str] = [] + header_names: list[str] = [] + active: bool = True + last_triggered_at: datetime | None = None + failure_count: int = 0 + created_at: datetime | None = None + secret: str | None = None + + +class WebhookDelivery(FopostModel): + """One delivery attempt. ``payload`` is only present on the single-item read.""" + + id: str + webhook_id: str | None = None + event: str | None = None + delivery_key: str | None = None + attempt: int | None = None + status: str | None = None + response_status: int | None = None + response_snippet: str | None = None + duration_ms: int | None = None + payload_hash: str | None = None + next_retry_at: datetime | None = None + created_at: datetime | None = None + payload: dict[str, Any] | None = None + + +# --- Unpublish and bulk import --------------------------------------------- + + +class UnpublishDelivery(FopostModel): + account_id: str + platform: str + removed: bool + + +class UnpublishResult(FopostModel): + post_status: str + deliveries: list[UnpublishDelivery] = [] + + +class BulkImportRow(FopostModel): + row: int | None = None + content_preview: str | None = None + content_type: str | None = None + thread_parts: int | None = None + schedule_at: str | None = None + repeat: dict[str, Any] | None = None + accounts: list[str] = [] + labels: int | None = None + has_media: bool | None = None + media_count: int | None = None + captions: int | None = None + errors: list[str] = [] + + +class BulkImportValidation(FopostModel): + total_rows: int + valid_rows: int + invalid_rows: int + max_rows: int + rows: list[BulkImportRow] = [] + + +class BulkImportCreatedPost(FopostModel): + id: str | None = None + schedule_at: datetime | None = None + + +class BulkImportResult(FopostModel): + batch_id: str + created: int + held_for_approval: bool + posts: list[BulkImportCreatedPost] = [] + + +class BulkImportRollback(FopostModel): + deleted: int + + +# --- Request logs ---------------------------------------------------------- + + +class RequestLog(FopostModel): + id: int | None = None + workspace_id: str | None = None + time: datetime | None = None + auth_method: str | None = None + method: str | None = None + route: str | None = None + path: str | None = None + status_code: int | None = None + duration_ms: int | None = None + platform: str | None = None + message: str | None = None + + +class RequestLogBucket(FopostModel): + at: datetime | None = None + total: int = 0 + errors: int = 0 + + +class RequestLogSummary(FopostModel): + since: datetime | None = None + total: int = 0 + errors: int = 0 + buckets: list[RequestLogBucket] = [] + + +class RequestLogMeta(FopostModel): + next_cursor: str | None = None + has_more: bool = False + + +class RequestLogPage(FopostModel): + """Request log rows, newest first, with a cursor for the next page.""" + + items: list[RequestLog] = [] + meta: RequestLogMeta = RequestLogMeta() + summary: RequestLogSummary | None = None + + +# --- X communities --------------------------------------------------------- + + +class Community(FopostModel): + """An X community linked to an account. ``id`` is the internal record id.""" + + id: int + account_id: str | None = None + community_id: str + name: str | None = None + member_count: int | None = None + description: str | None = None + image_url: str | None = None + last_synced_at: datetime | None = None + created_at: datetime | None = None + + +class CommunitySearchResult(FopostModel): + id: str + name: str | None = None + description: str | None = None + member_count: int | None = None + + +# --- Automations ----------------------------------------------------------- + +AUTOMATION_TRIGGER_TYPES: tuple[str, ...] = ( + "content_created", + "cross_post", + "rss_feed", + "api_webhook", + "schedule", + "competitor_post", + "post_outperformed", + "inbox_topic", + "inbox_message", + "radar_mention", +) + + +class AutomationNodeLayout(FopostModel): + x: float + y: float + + +class AutomationNode(FopostModel): + id: str + kind: str + label: str | None = None + config: dict[str, Any] = {} + position: int | None = None + layout: AutomationNodeLayout | None = None + + +class AutomationEdge(FopostModel): + id: str + source: str + source_handle: str | None = None + target: str + + +class AutomationGraph(FopostModel): + nodes: list[AutomationNode] = [] + edges: list[AutomationEdge] = [] + + +class AutomationStep(FopostModel): + position: int | None = None + node_id: str | None = None + action_type: str + action_config: dict[str, Any] = {} + + +class AutomationScheduleWindow(FopostModel): + days: list[int] = [] + start: str | None = None + end: str | None = None + + +class Automation(FopostModel): + """A trigger → pipeline workflow. ``secret`` is only present on create.""" + + id: str + workspace_id: str | None = None + name: str + description: str | None = None + trigger_type: str + trigger_config: dict[str, Any] = {} + active: bool = False + schedule_window: AutomationScheduleWindow | None = None + max_per_day: dict[str, Any] | None = None + credit_ceiling: int | None = None + approval_mode: bool | None = None + last_triggered_at: datetime | None = None + run_count: int | None = None + graph: AutomationGraph | None = None + steps: list[AutomationStep] = [] + secret: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class AutomationToggle(FopostModel): + id: str + active: bool + + +class AutomationRunSummary(FopostModel): + id: int + automation_id: str | None = None + status: str + started_at: datetime | None = None + completed_at: datetime | None = None + + +class AutomationStats(FopostModel): + total_automations: int = 0 + active_automations: int = 0 + total_runs_24h: int = 0 + runs_by_status: dict[str, int] = {} + recent_runs: list[AutomationRunSummary] = [] + + +class AutomationRunLog(FopostModel): + id: int + step_position: int | None = None + node_id: str | None = None + handle: str | None = None + status: str + input_snapshot: dict[str, Any] | None = None + output_snapshot: dict[str, Any] | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + duration_ms: int | None = None + error_message: str | None = None + + +class AutomationRun(FopostModel): + id: int + automation_id: str | None = None + status: str + current_step: int | None = None + current_node_id: str | None = None + waiting_until: datetime | None = None + trigger_event: dict[str, Any] | None = None + context: dict[str, Any] | None = None + initial_context: dict[str, Any] | None = None + replay_of_run_id: int | None = None + deferred_until: datetime | None = None + defer_reason: str | None = None + defer_detail: str | None = None + defer_count: int = 0 + started_at: datetime | None = None + completed_at: datetime | None = None + error_message: str | None = None + logs: list[AutomationRunLog] = [] + + +class AutomationVersionAuthor(FopostModel): + name: str + + +class AutomationVersionNode(FopostModel): + id: str + kind: str + label: str | None = None + + +class AutomationVersion(FopostModel): + """One saved graph. ``graph`` is only present on the single-version read.""" + + version: int + note: str | None = None + created_by: AutomationVersionAuthor | None = None + created_at: datetime | None = None + node_count: int | None = None + nodes_added: list[AutomationVersionNode] = [] + nodes_removed: list[AutomationVersionNode] = [] + graph: AutomationGraph | None = None + + +class AutomationRestoredVersion(FopostModel): + version: int + graph: AutomationGraph + steps: list[AutomationStep] = [] + + +class AutomationPageMeta(FopostModel): + page: int | None = None + per_page: int | None = None + total: int | None = None + total_pages: int | None = None + + +# --- Dashboard analytics --------------------------------------------------- + + +class AnalyticsDeltas(FopostModel): + followers: float | None = None + posts: float | None = None + engagement: float | None = None + impressions: float | None = None + likes: float | None = None + comments: float | None = None + shares: float | None = None + profile_views: float | None = None + + +class AnalyticsTodayStats(FopostModel): + posts: int = 0 + follower_change: int = 0 + engagement: int = 0 + + +class AnalyticsPlatformTotals(FopostModel): + platform: str + accounts: int = 0 + followers: int = 0 + + +class FollowerHistoryPoint(FopostModel): + date: str | None = None + followers: int | None = None + + +class AnalyticsAccountSummary(FopostModel): + account_id: str + platform: str + username: str | None = None + name: str | None = None + avatar: str | None = None + followers: int | None = None + total_posts: int | None = None + fetched_at: datetime | None = None + history: list[FollowerHistoryPoint] = [] + + +class AnalyticsOverview(FopostModel): + total_accounts: int = 0 + total_followers: int = 0 + total_posts: int = 0 + total_engagement: int = 0 + total_impressions: int = 0 + total_reach: int = 0 + total_likes: int = 0 + total_comments: int = 0 + total_shares: int = 0 + total_reposts: int = 0 + total_saves: int = 0 + total_clicks: int = 0 + total_video_views: int = 0 + total_profile_views: int = 0 + engagement_rate: float | None = None + deltas: AnalyticsDeltas | None = None + today_stats: AnalyticsTodayStats | None = None + platforms: list[AnalyticsPlatformTotals] = [] + accounts: list[AnalyticsAccountSummary] = [] + + +class TimeSeriesPoint(FopostModel): + date: str | None = None + engagements: int = 0 + impressions: int = 0 + likes: int = 0 + comments: int = 0 + shares: int = 0 + followers: int = 0 + posts: int = 0 + + +class AnalyticsTimeSeries(FopostModel): + days: int + series: list[TimeSeriesPoint] = [] + + +class TopPostPlatform(FopostModel): + platform: str + username: str | None = None + url: str | None = None + removed: bool | None = None + + +class TopPostLabel(FopostModel): + id: str + name: str + color: str | None = None + + +class TopPostMetrics(FopostModel): + engagements: int = 0 + impressions: int = 0 + reach: int = 0 + likes: int = 0 + comments: int = 0 + shares: int = 0 + reposts: int = 0 + clicks: int = 0 + saves: int = 0 + video_views: int = 0 + + +class TopPost(FopostModel): + rank: int | None = None + post_id: str | None = None + external_post_id: str | None = None + source: str | None = None + preview: str | None = None + permalink: str | None = None + thumbnail_url: str | None = None + status: str | None = None + created_at: datetime | None = None + platforms: list[TopPostPlatform] = [] + labels: list[TopPostLabel] = [] + metrics: TopPostMetrics | None = None + + +class LabelAnalytics(FopostModel): + label_id: str + name: str + color: str | None = None + post_count: int = 0 + impressions: int = 0 + reach: int = 0 + engagements: int = 0 + likes: int = 0 + comments: int = 0 + shares: int = 0 + engagement_rate: float | None = None + follower_delta: int | None = None + + +class PostsTablePlatform(FopostModel): + platform: str + username: str | None = None + url: str | None = None + delivery_status: str | None = None + + +class PostsTableDeliverySummary(FopostModel): + total: int = 0 + published: int = 0 + failed: int = 0 + pending: int = 0 + + +class PostsTableRow(FopostModel): + post_id: str + preview: str | None = None + status: str | None = None + created_at: datetime | None = None + scheduled_at: datetime | None = None + platforms: list[PostsTablePlatform] = [] + delivery_summary: PostsTableDeliverySummary | None = None + + +class PostsTableSummary(FopostModel): + draft: int = 0 + scheduled: int = 0 + published: int = 0 + failed: int = 0 + pending: int = 0 + total: int = 0 + + +class PostsTable(FopostModel): + """Posts with per-delivery outcomes; ``summary`` counts the whole filtered set.""" + + items: list[PostsTableRow] = [] + meta: AutomationPageMeta = AutomationPageMeta() + summary: PostsTableSummary | None = None + + +class PostingStreakDay(FopostModel): + date: str + count: int = 0 + published_count: int = 0 + failed_count: int = 0 + scheduled_count: int = 0 + + +class DemographicBucket(FopostModel): + key: str + value: float + share: float + + +class DemographicDimensions(FopostModel): + age: list[DemographicBucket] = [] + gender: list[DemographicBucket] = [] + country: list[DemographicBucket] = [] + city: list[DemographicBucket] = [] + + +class DemographicAccountRef(FopostModel): + account_id: str + platform: str + username: str | None = None + + +class AudienceDemographics(FopostModel): + audience: str + dimensions: DemographicDimensions + contributing_accounts: list[DemographicAccountRef] = [] + unsupported_accounts: list[DemographicAccountRef] = [] + + +class InboxAnalyticsTotals(FopostModel): + received: int = 0 + sent: int = 0 + answered: int = 0 + open: int = 0 + conversations: int = 0 + median_response_minutes: float | None = None + + +class InboxAnalyticsDeltas(FopostModel): + received: float | None = None + sent: float | None = None + answered: float | None = None + + +class InboxAnalyticsPoint(FopostModel): + date: str | None = None + received: int = 0 + sent: int = 0 + answered: int = 0 + + +class InboxAnalyticsPlatform(FopostModel): + platform: str + received: int = 0 + sent: int = 0 + + +class InboxAnalyticsType(FopostModel): + type: str + count: int = 0 + + +class InboxAnalyticsAccount(FopostModel): + account_id: str + platform: str + username: str | None = None + name: str | None = None + avatar: str | None = None + received: int = 0 + sent: int = 0 + + +class InboxResponseTimeBucket(FopostModel): + bucket: str + count: int = 0 + + +class InboxHeatmapCell(FopostModel): + weekday: int + hour: int + count: int = 0 + + +class InboxAnalytics(FopostModel): + totals: InboxAnalyticsTotals | None = None + deltas: InboxAnalyticsDeltas | None = None + series: list[InboxAnalyticsPoint] = [] + by_platform: list[InboxAnalyticsPlatform] = [] + by_type: list[InboxAnalyticsType] = [] + by_account: list[InboxAnalyticsAccount] = [] + response_time: list[InboxResponseTimeBucket] = [] + heatmap: list[InboxHeatmapCell] = [] + time_zone: str | None = None + + +class AnalyticsCollectError(FopostModel): + account_id: str | None = None + platform: str | None = None + username: str | None = None + stage: str | None = None + message: str | None = None + action: str | None = None + + +class AnalyticsCollectResult(FopostModel): + accounts: int = 0 + posts: int = 0 + demographics: int = 0 + errors: int = 0 + error_details: list[AnalyticsCollectError] = [] diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index ade3a96..c9ee59a 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -4,17 +4,22 @@ from .ads import AdsResource from .ai import AiResource from .analytics import AnalyticsResource +from .automations import AutomationsResource from .blog import BlogResource from .blogs import BlogsResource from .broadcasts import BroadcastsResource, SequencesResource +from .connect_sessions import ConnectSessionsResource from .contacts import ContactsResource from .google_business import GoogleBusinessResource from .inbox import InboxResource from .knowledge import KnowledgeResource from .labels import LabelsResource +from .logs import LogsResource from .media import MediaResource from .posts import PostsResource +from .queue import QueueResource from .validate import ValidateResource +from .webhook_endpoints import WebhookEndpointsResource from .whatsapp import WhatsappResource from .workspaces import WorkspacesResource @@ -22,6 +27,11 @@ "AccountGroupsResource", "AccountsResource", "AnalyticsResource", + "AutomationsResource", + "ConnectSessionsResource", + "LogsResource", + "QueueResource", + "WebhookEndpointsResource", "ActivityResource", "AdsResource", "BlogsResource", diff --git a/src/fopost/resources/accounts.py b/src/fopost/resources/accounts.py index 730a4d2..e6dbd07 100644 --- a/src/fopost/resources/accounts.py +++ b/src/fopost/resources/accounts.py @@ -11,8 +11,13 @@ from ..models import ( AccountMove, AccountPlatformMetrics, + AccountPrimary, AccountRename, + AccountTokenRefresh, + AccountValidation, BlueskyLanguages, + Community, + CommunitySearchResult, DiscordChannel, DiscordIdentity, DiscordMember, @@ -117,6 +122,24 @@ def move(self, account_id: str, *, workspace_id: str) -> AccountMove: unwrap(self._http.post(f"/accounts/{account_id}/move", {"workspace_id": workspace_id})) ) + def toggle_primary(self, account_id: str) -> AccountPrimary: + """Flip the account's primary flag.""" + return AccountPrimary.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/primary")) + ) + + def validate_credentials(self, account_id: str) -> AccountValidation: + """Check the stored credentials against the platform and update health.""" + return AccountValidation.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/validate")) + ) + + def refresh_token(self, account_id: str) -> AccountTokenRefresh: + """Manually refresh the OAuth token, on platforms that support it.""" + return AccountTokenRefresh.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/refresh-token")) + ) + def create_telegram_connect_code( self, *, workspace_id: str | None = None ) -> TelegramConnectCode: @@ -778,6 +801,38 @@ def search_linkedin_mentions(self, account_id: str, q: str) -> builtins.list[Lin unwrap(self._http.get(f"/accounts/{account_id}/linkedin/mentions", params={"q": q})), ) + # X communities. Only X accounts have them. + + def list_communities(self, account_id: str) -> builtins.list[Community]: + return parse_list(Community, unwrap(self._http.get(f"/accounts/{account_id}/communities"))) + + def sync_communities(self, account_id: str) -> builtins.list[Community]: + """Fetch the account's communities from X, falling back to the stored list.""" + return parse_list( + Community, unwrap(self._http.post(f"/accounts/{account_id}/communities/sync")) + ) + + def search_communities(self, account_id: str, q: str) -> builtins.list[CommunitySearchResult]: + return parse_list( + CommunitySearchResult, + unwrap(self._http.get(f"/accounts/{account_id}/communities/search", {"q": q})), + ) + + def add_community( + self, account_id: str, *, community_id: str, name: str | None = None + ) -> Community: + """Link a community by its X id. An already-linked one comes back unchanged.""" + body: dict[str, Any] = {"communityId": community_id} + if name is not None: + body["name"] = name + return Community.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/communities/manual", body)) + ) + + def remove_community(self, account_id: str, community_id: int) -> None: + """``community_id`` is the internal record id from the list, not the X id.""" + self._http.delete(f"/accounts/{account_id}/communities/{community_id}") + def _discord_event_body(**fields: Any) -> dict[str, Any]: """Only the fields the caller named; Discord keeps the rest as they are.""" diff --git a/src/fopost/resources/analytics.py b/src/fopost/resources/analytics.py index 00fa258..e833d9a 100644 --- a/src/fopost/resources/analytics.py +++ b/src/fopost/resources/analytics.py @@ -11,14 +11,26 @@ from .._http import unwrap from ..models import ( + AnalyticsCollectResult, + AnalyticsOverview, + AnalyticsTimeSeries, + AudienceDemographics, + AutomationPageMeta, CollectPostResult, ContentDecay, + InboxAnalytics, + LabelAnalytics, MetricChangePage, NativePost, Page, PageMeta, PostingFrequency, + PostingStreakDay, + PostsTable, + PostsTableRow, + PostsTableSummary, PostTimeline, + TopPost, ) from ._base import Resource, parse_list @@ -26,6 +38,176 @@ class AnalyticsResource(Resource): + def overview( + self, + *, + days: int | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> AnalyticsOverview: + """Dashboard totals, period deltas, today's stats and a per-account breakdown.""" + body = self._http.get( + "/analytics/overview", + {"days": days, "workspace_id": workspace_id, "account_id": account_id}, + ) + return AnalyticsOverview.model_validate(unwrap(body)) + + def time_series( + self, + *, + days: int | None = None, + from_: str | None = None, + to: str | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> AnalyticsTimeSeries: + """Daily engagement, follower and post counts. Use ``days`` or ``from_``/``to``.""" + body = self._http.get( + "/analytics/time-series", + { + "days": days, + "from": from_, + "to": to, + "workspace_id": workspace_id, + "account_id": account_id, + }, + ) + return AnalyticsTimeSeries.model_validate(unwrap(body)) + + def top_posts( + self, + *, + limit: int | None = None, + days: int | None = None, + from_: str | None = None, + to: str | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + sort: str | None = None, + label: str | None = None, + ) -> list[TopPost]: + """Posts ranked by engagement, or newest first with ``sort="recent"``.""" + body = self._http.get( + "/analytics/top-posts", + { + "limit": limit, + "days": days, + "from": from_, + "to": to, + "workspace_id": workspace_id, + "account_id": account_id, + "sort": sort, + "label": label, + }, + ) + return parse_list(TopPost, unwrap(body)) + + def labels( + self, + *, + days: int | None = None, + from_: str | None = None, + to: str | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> list[LabelAnalytics]: + """Post metrics rolled up per label, ranked by engagement.""" + body = self._http.get( + "/analytics/labels", + { + "days": days, + "from": from_, + "to": to, + "workspace_id": workspace_id, + "account_id": account_id, + }, + ) + return parse_list(LabelAnalytics, unwrap(body)) + + def posts_table( + self, + *, + page: int = 1, + per_page: int = 20, + days: int | None = None, + from_: str | None = None, + to: str | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> PostsTable: + """Paginated posts of every status with per-delivery outcomes.""" + body = self._http.get( + "/analytics/posts-table", + { + "page": page, + "per_page": per_page, + "days": days, + "from": from_, + "to": to, + "workspace_id": workspace_id, + "account_id": account_id, + }, + ) + raw_meta = body.get("meta") if isinstance(body, dict) else None + raw_summary = body.get("summary") if isinstance(body, dict) else None + return PostsTable( + items=parse_list(PostsTableRow, unwrap(body)), + meta=AutomationPageMeta.model_validate(raw_meta) + if isinstance(raw_meta, dict) + else AutomationPageMeta(), + summary=PostsTableSummary.model_validate(raw_summary) + if isinstance(raw_summary, dict) + else None, + ) + + def posting_streak(self, *, workspace_id: str | None = None) -> list[PostingStreakDay]: + """365 days of posting activity, one row per day.""" + body = self._http.get("/analytics/posting-streak", {"workspace_id": workspace_id}) + data = unwrap(body) + if isinstance(data, dict): + data = data.get("streak") + return parse_list(PostingStreakDay, data) + + def demographics( + self, + *, + audience: str | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> AudienceDemographics: + """Age, gender, country and city splits. ``audience`` is followers, engaged or reached.""" + body = self._http.get( + "/analytics/demographics", + {"audience": audience, "workspace_id": workspace_id, "account_id": account_id}, + ) + return AudienceDemographics.model_validate(unwrap(body)) + + def inbox( + self, + *, + days: int | None = None, + tz: str | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> InboxAnalytics: + """How the social inbox performed over the period. Aggregates only.""" + body = self._http.get( + "/analytics/inbox", + {"days": days, "tz": tz, "workspace_id": workspace_id, "account_id": account_id}, + ) + return InboxAnalytics.model_validate(unwrap(body)) + + def collect( + self, *, workspace_id: str | None = None, account_id: str | None = None + ) -> AnalyticsCollectResult: + """Collect fresh metrics now. Throttled per user; a 429 carries ``retry_after``.""" + body = self._http.request( + "POST", + "/analytics/collect", + params={"workspace_id": workspace_id, "account_id": account_id}, + ) + return AnalyticsCollectResult.model_validate(unwrap(body)) + def decay( self, *, diff --git a/src/fopost/resources/automations.py b/src/fopost/resources/automations.py new file mode 100644 index 0000000..3fe2941 --- /dev/null +++ b/src/fopost/resources/automations.py @@ -0,0 +1,169 @@ +"""``client.automations`` — trigger → pipeline workflows, their versions and runs.""" + +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from .._http import unwrap +from ..models import ( + Automation, + AutomationPageMeta, + AutomationRestoredVersion, + AutomationRun, + AutomationRunSummary, + AutomationStats, + AutomationToggle, + AutomationVersion, +) +from ._base import UNSET, Resource, drop_unset, parse_list + +__all__ = ["AutomationsResource"] + + +def _camel_graph(graph: Mapping[str, Any]) -> dict[str, Any]: + """The write side spells ``sourceHandle`` in camelCase; accept either.""" + edges = [] + for edge in graph.get("edges", []): + edge = dict(edge) + if "source_handle" in edge: + edge["sourceHandle"] = edge.pop("source_handle") + edges.append(edge) + return {**dict(graph), "edges": edges} + + +def _camel_steps(steps: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + out = [] + for step in steps: + step = dict(step) + if "action_type" in step: + step["actionType"] = step.pop("action_type") + if "action_config" in step: + step["actionConfig"] = step.pop("action_config") + out.append(step) + return out + + +class AutomationsResource(Resource): + def list(self) -> builtins.list[Automation]: + return parse_list(Automation, unwrap(self._http.get("/automations"))) + + def create( + self, + *, + workspace_id: str, + name: str, + trigger_type: str, + description: str | None = None, + trigger_config: Mapping[str, Any] | None = None, + steps: Sequence[Mapping[str, Any]] | None = None, + graph: Mapping[str, Any] | None = None, + active: bool | None = None, + ) -> Automation: + """Create a workflow. The webhook trigger ``secret`` comes back only here. + + ``steps`` are ``{"action_type": ..., "action_config": {...}}`` dicts and + ``graph`` is ``{"nodes": [...], "edges": [...]}``; pass one or the other. + """ + body: dict[str, Any] = { + "workspaceId": workspace_id, + "name": name, + "triggerType": trigger_type, + } + optional: dict[str, Any] = { + "description": description, + "triggerConfig": dict(trigger_config) if trigger_config is not None else None, + "steps": _camel_steps(steps) if steps is not None else None, + "graph": _camel_graph(graph) if graph is not None else None, + "active": active, + } + body.update({k: v for k, v in optional.items() if v is not None}) + return Automation.model_validate(unwrap(self._http.post("/automations", body))) + + def get(self, automation_id: str) -> Automation: + return Automation.model_validate(unwrap(self._http.get(f"/automations/{automation_id}"))) + + def update( + self, + automation_id: str, + *, + name: str | Any = UNSET, + description: str | None | Any = UNSET, + trigger_type: str | Any = UNSET, + trigger_config: Mapping[str, Any] | Any = UNSET, + steps: Sequence[Mapping[str, Any]] | Any = UNSET, + graph: Mapping[str, Any] | Any = UNSET, + active: bool | Any = UNSET, + ) -> Automation: + """Partial update. Passing ``graph`` or ``steps`` replaces the whole graph.""" + body = drop_unset( + { + "name": name, + "description": description, + "triggerType": trigger_type, + "triggerConfig": dict(trigger_config) if trigger_config is not UNSET else UNSET, + "steps": _camel_steps(steps) if steps is not UNSET else UNSET, + "graph": _camel_graph(graph) if graph is not UNSET else UNSET, + "active": active, + } + ) + return Automation.model_validate( + unwrap(self._http.put(f"/automations/{automation_id}", body)) + ) + + def delete(self, automation_id: str) -> None: + self._http.delete(f"/automations/{automation_id}") + + def toggle(self, automation_id: str) -> AutomationToggle: + """Flip the automation on or off.""" + return AutomationToggle.model_validate( + unwrap(self._http.post(f"/automations/{automation_id}/toggle")) + ) + + def stats(self) -> AutomationStats: + """Totals, run counts by status for the last 24 hours, and the 10 latest runs.""" + return AutomationStats.model_validate(unwrap(self._http.get("/automations/stats"))) + + def versions( + self, automation_id: str, *, page: int = 1, per_page: int = 30 + ) -> builtins.list[AutomationVersion]: + """Every saved graph, newest first, with what each save added and removed.""" + body = self._http.get( + f"/automations/{automation_id}/versions", {"page": page, "per_page": per_page} + ) + return parse_list(AutomationVersion, unwrap(body)) + + def get_version(self, automation_id: str, version: int) -> AutomationVersion: + """The graph as it was saved at that version.""" + return AutomationVersion.model_validate( + unwrap(self._http.get(f"/automations/{automation_id}/versions/{version}")) + ) + + def restore_version(self, automation_id: str, version: int) -> AutomationRestoredVersion: + """Save that version's graph as a new version on top of the history.""" + return AutomationRestoredVersion.model_validate( + unwrap(self._http.post(f"/automations/{automation_id}/versions/{version}/restore")) + ) + + def runs( + self, automation_id: str, *, page: int = 1, per_page: int = 30 + ) -> tuple[builtins.list[AutomationRunSummary], AutomationPageMeta]: + """One page of runs plus its meta.""" + body = self._http.get( + f"/automations/{automation_id}/runs", {"page": page, "per_page": per_page} + ) + items = parse_list(AutomationRunSummary, unwrap(body)) + raw_meta = body.get("meta") if isinstance(body, dict) else None + meta = ( + AutomationPageMeta.model_validate(raw_meta) + if isinstance(raw_meta, dict) + else AutomationPageMeta() + ) + return items, meta + + def get_run(self, automation_id: str, run_id: int) -> AutomationRun: + """One run with its per-step execution logs.""" + return AutomationRun.model_validate( + unwrap(self._http.get(f"/automations/{automation_id}/runs/{run_id}")) + ) diff --git a/src/fopost/resources/connect_sessions.py b/src/fopost/resources/connect_sessions.py new file mode 100644 index 0000000..2dcd94b --- /dev/null +++ b/src/fopost/resources/connect_sessions.py @@ -0,0 +1,51 @@ +"""``client.connect_sessions`` — hosted connect links for your end users.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from .._http import unwrap +from ..models import ConnectSession +from ._base import Resource + +__all__ = ["ConnectSessionsResource"] + + +class ConnectSessionsResource(Resource): + def create( + self, + *, + external_user_id: str, + platforms: Sequence[str], + workspace_id: str | None = None, + redirect_url: str | None = None, + branding: Mapping[str, str] | None = None, + ) -> ConnectSession: + """Mint a single-use link, open for one hour, that connects an account. + + ``redirect_url`` must be on a domain in the workspace redirect allowlist. + ``branding`` takes ``name`` and ``logo_url``. ``workspace_id`` may be + omitted for a key bound to one workspace. + """ + body: dict[str, Any] = { + "externalUserId": external_user_id, + "platforms": list(platforms), + } + if workspace_id is not None: + body["workspaceId"] = workspace_id + if redirect_url is not None: + body["redirectUrl"] = redirect_url + if branding is not None: + body["branding"] = { + "name": branding.get("name"), + "logoUrl": branding.get("logo_url", branding.get("logoUrl")), + } + body["branding"] = {k: v for k, v in body["branding"].items() if v is not None} + return ConnectSession.model_validate(unwrap(self._http.post("/connect-sessions", body))) + + def get(self, session_id: str) -> ConnectSession: + """Poll the outcome: ``status`` moves to ``used`` once ``account_id`` is set.""" + return ConnectSession.model_validate( + unwrap(self._http.get(f"/connect-sessions/{session_id}")) + ) diff --git a/src/fopost/resources/labels.py b/src/fopost/resources/labels.py index ee5b513..f112129 100644 --- a/src/fopost/resources/labels.py +++ b/src/fopost/resources/labels.py @@ -12,3 +12,19 @@ class LabelsResource(Resource): def list(self, *, workspace_id: str | None = None) -> list[Label]: return parse_list(Label, unwrap(self._http.get("/labels", {"workspace_id": workspace_id}))) + + def create(self, *, workspace_id: str, name: str, color: str) -> Label: + body = {"workspace_id": workspace_id, "name": name, "color": color} + return Label.model_validate(unwrap(self._http.post("/labels", body))) + + def get(self, label_id: str) -> Label: + return Label.model_validate(unwrap(self._http.get(f"/labels/{label_id}"))) + + def update(self, label_id: str, *, name: str, color: str) -> Label: + """Both fields are required by the API, so this is a full replace.""" + return Label.model_validate( + unwrap(self._http.put(f"/labels/{label_id}", {"name": name, "color": color})) + ) + + def delete(self, label_id: str) -> None: + self._http.delete(f"/labels/{label_id}") diff --git a/src/fopost/resources/logs.py b/src/fopost/resources/logs.py new file mode 100644 index 0000000..38939ba --- /dev/null +++ b/src/fopost/resources/logs.py @@ -0,0 +1,54 @@ +"""``client.logs`` — recent API request activity, kept for 30 days.""" + +from __future__ import annotations + +from .._http import unwrap +from ..models import RequestLog, RequestLogMeta, RequestLogPage, RequestLogSummary +from ._base import Resource, parse_list + +__all__ = ["LogsResource"] + + +class LogsResource(Resource): + def list( + self, + *, + workspace_id: str | None = None, + days: int | None = None, + status: str | None = None, + method: str | None = None, + status_code: int | None = None, + platform: str | None = None, + route: str | None = None, + q: str | None = None, + limit: int | None = None, + before: int | None = None, + ) -> RequestLogPage: + """Request rows newest first. ``before`` is ``meta.next_cursor`` from the last page.""" + body = self._http.get( + "/logs", + { + "workspace_id": workspace_id, + "days": days, + "status": status, + "method": method, + "status_code": status_code, + "platform": platform, + "route": route, + "q": q, + "limit": limit, + "before": before, + }, + ) + items = parse_list(RequestLog, unwrap(body)) + raw_meta = body.get("meta") if isinstance(body, dict) else None + raw_summary = body.get("summary") if isinstance(body, dict) else None + return RequestLogPage( + items=items, + meta=RequestLogMeta.model_validate(raw_meta) + if isinstance(raw_meta, dict) + else RequestLogMeta(), + summary=RequestLogSummary.model_validate(raw_summary) + if isinstance(raw_summary, dict) + else None, + ) diff --git a/src/fopost/resources/media.py b/src/fopost/resources/media.py index 0feecf8..070e4cc 100644 --- a/src/fopost/resources/media.py +++ b/src/fopost/resources/media.py @@ -2,13 +2,14 @@ from __future__ import annotations -from collections.abc import Mapping +import builtins +from collections.abc import Mapping, Sequence from typing import Any from .._http import unwrap from ..errors import error_from_response -from ..models import MediaItem, PresignedUpload, UploadLimits -from ._base import Resource +from ..models import MediaItem, Page, PageMeta, PresignedUpload, UploadLimits +from ._base import Resource, parse_list __all__ = ["MediaResource"] @@ -17,6 +18,74 @@ class MediaResource(Resource): + def list( + self, + *, + workspace_id: str | None = None, + folder_id: str | None = None, + search: str | None = None, + tag: str | None = None, + type: str | None = None, + url: str | None = None, + brand_role: str | None = None, + brand_voice_id: str | None = None, + generation_run_id: str | None = None, + origin: str | None = None, + shared: bool | None = None, + page: int = 1, + per_page: int = 60, + ) -> Page[MediaItem]: + """Library items newest first. ``folder_id="root"`` lists unfiled assets.""" + body = self._http.get( + "/media", + { + "workspace_id": workspace_id, + "folder_id": folder_id, + "search": search, + "tag": tag, + "type": type, + "url": url, + "brand_role": brand_role, + "brand_voice_id": brand_voice_id, + "generation_run_id": generation_run_id, + "origin": origin, + "shared": None if shared is None else ("true" if shared else "false"), + "page": page, + "per_page": per_page, + }, + ) + items = parse_list(MediaItem, body.get("data") if isinstance(body, dict) else body) + raw_meta = body.get("meta") if isinstance(body, dict) else None + meta = PageMeta.model_validate(raw_meta) if isinstance(raw_meta, dict) else PageMeta() + return Page[MediaItem](items=items, meta=meta) + + def upload( + self, + files: Sequence[tuple[str, bytes, str]], + *, + workspace_id: str | None = None, + ) -> builtins.list[MediaItem]: + """Multipart upload of up to 5 ``(filename, bytes, mime_type)`` parts. + + Each part must fit ``multipart_max_file_bytes``; a larger file goes + through :meth:`upload_direct`. With ``workspace_id`` the files are also + saved to the media library. + """ + fields: dict[str, Any] = {} + if workspace_id is not None: + fields["workspaceId"] = workspace_id + body = self._http.send_multipart( + "POST", + "/media/upload", + fields=fields, + files=[("files", (name, bytes(data), mime)) for name, data, mime in files], + ) + return parse_list(MediaItem, unwrap(body)) + + def delete(self, media_id: str) -> None: + """Remove the stored file and the library record.""" + self._http.delete(f"/media/{media_id}") + def limits(self, *, workspace_id: str | None = None) -> UploadLimits: """The upload ceilings for the caller's account.""" return UploadLimits.model_validate( diff --git a/src/fopost/resources/posts.py b/src/fopost/resources/posts.py index 09300cf..520d7cd 100644 --- a/src/fopost/resources/posts.py +++ b/src/fopost/resources/posts.py @@ -15,7 +15,19 @@ from .._http import unwrap from ..errors import FopostError -from ..models import ContentBlock, Delivery, MediaItem, Page, PageMeta, Post, SocialAccount +from ..models import ( + BulkImportResult, + BulkImportRollback, + BulkImportValidation, + ContentBlock, + Delivery, + MediaItem, + Page, + PageMeta, + Post, + SocialAccount, + UnpublishResult, +) from ._base import UNSET, Resource, parse_list from .media import DEFAULT_MULTIPART_MAX_FILE_BYTES, MediaResource @@ -212,6 +224,7 @@ def create( account_group_id: str | None = None, status: str = "draft", schedule_at: str | datetime | None = None, + schedule: str | None = None, labels: Sequence[str] | None = None, title: str | None = None, summary: str | None = None, @@ -223,7 +236,8 @@ def create( """Create a draft or a scheduled post. ``status`` is ``draft`` or ``scheduled``; a scheduled post needs - ``schedule_at``. To send a post out now, create it and call + ``schedule_at``, or ``schedule="queue"`` to take the next free posting + slot instead. To send a post out now, create it and call :meth:`publish`. ``account_group_id`` adds that group's accounts to ``accounts``, so ``accounts`` may be left empty. @@ -240,6 +254,7 @@ def create( optional = { "account_group_id": account_group_id, "schedule_at": _iso(schedule_at), + "schedule": schedule, "labels": list(labels) if labels is not None else None, "title": title, "summary": summary, @@ -265,6 +280,7 @@ def update( accounts: Sequence[AccountInput] | Any = UNSET, status: str | Any = UNSET, schedule_at: str | datetime | None | Any = UNSET, + schedule: str | Any = UNSET, labels: Sequence[str] | Any = UNSET, title: str | None | Any = UNSET, summary: str | None | Any = UNSET, @@ -275,6 +291,7 @@ def update( ) -> Post: """Partial update — only the fields you pass are sent. + ``schedule="queue"`` moves the post onto the next free posting slot. ``files`` are attached to the first content block, as in :meth:`create`. """ body: dict[str, Any] = {} @@ -286,6 +303,8 @@ def update( body["status"] = status if schedule_at is not UNSET: body["schedule_at"] = _iso(schedule_at) + if schedule is not UNSET: + body["schedule"] = schedule if labels is not UNSET: body["labels"] = list(labels) if title is not UNSET: @@ -358,10 +377,46 @@ def preflight(self, post_id: str) -> dict[str, Any]: """Per-account blockers and advisory content signals, without publishing.""" return _as_dict(unwrap(self._http.post(f"/posts/{post_id}/preflight"))) + def unpublish(self, post_id: str) -> UnpublishResult: + """Take a published post down on every network it is live on. + + Refused as a whole with 409 ``unsupported_platforms`` when one of those + networks cannot delete a live post. + """ + return UnpublishResult.model_validate( + unwrap(self._http.post(f"/posts/{post_id}/unpublish")) + ) + # builtins.list, because `list` is a method on this class. def deliveries(self, post_id: str) -> builtins.list[Delivery]: return parse_list(Delivery, unwrap(self._http.get(f"/posts/{post_id}/deliveries"))) + def validate_bulk_import(self, *, workspace_id: str, file: FileInput) -> BulkImportValidation: + """Dry-run a CSV/TSV import and return per-row results without creating anything.""" + body = self._http.send_multipart( + "POST", + "/posts/bulk-import/validate", + fields={"workspace_id": workspace_id}, + files=[("file", _read_file(file))], + ) + return BulkImportValidation.model_validate(unwrap(body)) + + def commit_bulk_import(self, *, workspace_id: str, file: FileInput) -> BulkImportResult: + """Create one scheduled post per row, all-or-nothing, stamped with a batch id.""" + body = self._http.send_multipart( + "POST", + "/posts/bulk-import/commit", + fields={"workspace_id": workspace_id}, + files=[("file", _read_file(file))], + ) + return BulkImportResult.model_validate(unwrap(body)) + + def rollback_bulk_import(self, batch_id: str) -> BulkImportRollback: + """Soft-delete the batch's posts still draft or scheduled. 409 once any published.""" + return BulkImportRollback.model_validate( + unwrap(self._http.delete(f"/posts/bulk-import/{batch_id}")) + ) + def _as_dict(body: Any) -> dict[str, Any]: return body if isinstance(body, dict) else {"data": body} diff --git a/src/fopost/resources/queue.py b/src/fopost/resources/queue.py new file mode 100644 index 0000000..d8917c0 --- /dev/null +++ b/src/fopost/resources/queue.py @@ -0,0 +1,84 @@ +"""``client.queue`` — the posting queue: weekly slots and the next free times.""" + +from __future__ import annotations + +import builtins +from datetime import datetime +from typing import Any + +from .._http import unwrap +from ..models import QueueNext, QueueSlot, QueueSlotDeletion, QueueSlotUpdate +from ._base import UNSET, Resource, drop_unset, parse_list + +__all__ = ["QueueResource"] + + +class QueueResource(Resource): + def list_slots(self, *, workspace_id: str) -> builtins.list[QueueSlot]: + """The recurring weekly slots of a workspace, ordered by weekday and time.""" + return parse_list( + QueueSlot, unwrap(self._http.get("/queue/slots", {"workspace_id": workspace_id})) + ) + + def create_slot( + self, + *, + workspace_id: str, + weekday: int, + time: str, + timezone: str, + account_id: str | None = None, + ) -> QueueSlot: + """Add a weekly slot. Omit ``account_id`` for one every account shares.""" + body: dict[str, Any] = { + "workspace_id": workspace_id, + "weekday": weekday, + "time": time, + "timezone": timezone, + } + if account_id is not None: + body["account_id"] = account_id + return QueueSlot.model_validate(unwrap(self._http.post("/queue/slots", body))) + + def update_slot( + self, + slot_id: str, + *, + account_id: str | None | Any = UNSET, + weekday: int | Any = UNSET, + time: str | Any = UNSET, + timezone: str | Any = UNSET, + ) -> QueueSlotUpdate: + """Change a slot. Queued posts that no longer fit are moved and listed in ``moved``.""" + body = drop_unset( + {"account_id": account_id, "weekday": weekday, "time": time, "timezone": timezone} + ) + return QueueSlotUpdate.model_validate( + unwrap(self._http.put(f"/queue/slots/{slot_id}", body)) + ) + + def delete_slot(self, slot_id: str) -> QueueSlotDeletion: + """Remove a slot. Posts that sat on it move to the next free occurrence.""" + return QueueSlotDeletion.model_validate( + unwrap(self._http.delete(f"/queue/slots/{slot_id}")) + ) + + def next(self, *, workspace_id: str, account_id: str | None = None) -> datetime | None: + """The time a post added to the queue now would get, or ``None`` with no slots.""" + body = self._http.get( + "/queue/next", {"workspace_id": workspace_id, "account_id": account_id} + ) + return QueueNext.model_validate(unwrap(body)).next + + def preview( + self, *, workspace_id: str, account_id: str | None = None, count: int | None = None + ) -> builtins.list[datetime]: + """The next free occurrences in order, one per post that would be queued.""" + body = self._http.get( + "/queue/preview", + {"workspace_id": workspace_id, "account_id": account_id, "count": count}, + ) + data = unwrap(body) + if not isinstance(data, list): + return [] + return [datetime.fromisoformat(str(item).replace("Z", "+00:00")) for item in data] diff --git a/src/fopost/resources/webhook_endpoints.py b/src/fopost/resources/webhook_endpoints.py new file mode 100644 index 0000000..e7bce75 --- /dev/null +++ b/src/fopost/resources/webhook_endpoints.py @@ -0,0 +1,98 @@ +"""``client.webhook_endpoints`` — the HTTPS endpoints FoPost delivers events to. + +Named apart from :mod:`fopost.webhooks`, which verifies the deliveries these +endpoints receive. +""" + +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from .._http import unwrap +from ..models import WebhookDelivery, WebhookEndpoint +from ._base import UNSET, Resource, drop_unset, parse_list + +__all__ = ["WebhookEndpointsResource"] + + +class WebhookEndpointsResource(Resource): + def list(self) -> builtins.list[WebhookEndpoint]: + return parse_list(WebhookEndpoint, unwrap(self._http.get("/webhooks"))) + + def create( + self, + *, + workspace_id: str, + url: str, + events: Sequence[str], + name: str | None = None, + secret: str | None = None, + headers: Mapping[str, str] | None = None, + ) -> WebhookEndpoint: + """Register an endpoint. The signing ``secret`` comes back only here.""" + body: dict[str, Any] = {"workspaceId": workspace_id, "url": url, "events": list(events)} + optional = { + "name": name, + "secret": secret, + "headers": dict(headers) if headers is not None else None, + } + body.update({k: v for k, v in optional.items() if v is not None}) + return WebhookEndpoint.model_validate(unwrap(self._http.post("/webhooks", body))) + + def update( + self, + webhook_id: str, + *, + name: str | None | Any = UNSET, + url: str | Any = UNSET, + events: Sequence[str] | Any = UNSET, + active: bool | Any = UNSET, + secret: str | Any = UNSET, + headers: Mapping[str, str] | None | Any = UNSET, + ) -> WebhookEndpoint: + """Partial update. ``active=True`` on a paused webhook resets its failure count.""" + body = drop_unset( + { + "name": name, + "url": url, + "events": list(events) if events is not UNSET else UNSET, + "active": active, + "secret": secret, + "headers": dict(headers) if isinstance(headers, Mapping) else headers, + } + ) + return WebhookEndpoint.model_validate( + unwrap(self._http.put(f"/webhooks/{webhook_id}", body)) + ) + + def delete(self, webhook_id: str) -> None: + self._http.delete(f"/webhooks/{webhook_id}") + + def test(self, webhook_id: str) -> None: + """Dispatch a test ``post.published`` payload to this endpoint only.""" + self._http.post(f"/webhooks/{webhook_id}/test") + + def deliveries( + self, webhook_id: str, *, status: str | None = None, limit: int | None = None + ) -> builtins.list[WebhookDelivery]: + """Attempts from the last 30 days, newest first. ``status`` is ``success`` or ``failed``.""" + return parse_list( + WebhookDelivery, + unwrap( + self._http.get( + f"/webhooks/{webhook_id}/deliveries", {"status": status, "limit": limit} + ) + ), + ) + + def get_delivery(self, webhook_id: str, delivery_id: str) -> WebhookDelivery: + """One attempt, including the payload that was sent.""" + return WebhookDelivery.model_validate( + unwrap(self._http.get(f"/webhooks/{webhook_id}/deliveries/{delivery_id}")) + ) + + def redeliver(self, webhook_id: str, delivery_id: str) -> None: + """Send the identical body again, logged as the next attempt. A paused webhook is 409.""" + self._http.post(f"/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver") diff --git a/src/fopost/resources/workspaces.py b/src/fopost/resources/workspaces.py index 9fa848f..acb5329 100644 --- a/src/fopost/resources/workspaces.py +++ b/src/fopost/resources/workspaces.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Sequence +from typing import Any + from .._http import unwrap from ..models import Workspace -from ._base import Resource, parse_list +from ._base import UNSET, Resource, drop_unset, parse_list __all__ = ["WorkspacesResource"] @@ -15,3 +18,72 @@ def list(self) -> list[Workspace]: def get(self, workspace_id: str) -> Workspace: return Workspace.model_validate(unwrap(self._http.get(f"/workspaces/{workspace_id}"))) + + def create( + self, + *, + name: str, + type: str | None = None, + logo: str | None = None, + website: str | None = None, + timezone: str | None = None, + country: str | None = None, + description: str | None = None, + language: str | None = None, + ) -> Workspace: + body: dict[str, Any] = {"name": name} + optional = { + "type": type, + "logo": logo, + "website": website, + "timezone": timezone, + "country": country, + "description": description, + "language": language, + } + body.update({k: v for k, v in optional.items() if v is not None}) + return Workspace.model_validate(unwrap(self._http.post("/workspaces", body))) + + def update( + self, + workspace_id: str, + *, + name: str | Any = UNSET, + type: str | Any = UNSET, + logo: str | None | Any = UNSET, + website: str | None | Any = UNSET, + timezone: str | Any = UNSET, + country: str | None | Any = UNSET, + description: str | None | Any = UNSET, + language: str | Any = UNSET, + require_approval: bool | Any = UNSET, + ai_alt_text_enabled: bool | Any = UNSET, + brand_color: str | None | Any = UNSET, + connect_redirect_domains: Sequence[str] | Any = UNSET, + ) -> Workspace: + """Partial update; needs the owner or admin role.""" + body = drop_unset( + { + "name": name, + "type": type, + "logo": logo, + "website": website, + "timezone": timezone, + "country": country, + "description": description, + "language": language, + "requireApproval": require_approval, + "aiAltTextEnabled": ai_alt_text_enabled, + "brandColor": brand_color, + "connectRedirectDomains": ( + list(connect_redirect_domains) + if connect_redirect_domains is not UNSET + else UNSET + ), + } + ) + return Workspace.model_validate(unwrap(self._http.put(f"/workspaces/{workspace_id}", body))) + + def delete(self, workspace_id: str) -> None: + """Soft-delete. 409 while accounts are still connected or it is your only workspace.""" + self._http.delete(f"/workspaces/{workspace_id}") diff --git a/tests/test_accounts.py b/tests/test_accounts.py index ab7cb29..c5ff08d 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -531,3 +531,35 @@ 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_primary_validate_and_refresh_token_post_to_the_account(client: Fopost) -> None: + primary = respx.post(f"{BASE_URL}/accounts/acc_1/primary").mock( + return_value=httpx.Response(200, json={"id": "acc_1", "is_primary": True}) + ) + validate = respx.post(f"{BASE_URL}/accounts/acc_1/validate").mock( + return_value=httpx.Response( + 200, + json={ + "account_id": "acc_1", + "platform": "twitter", + "valid": False, + "health_status": "expired", + "message": "Token expired", + }, + ) + ) + refresh = respx.post(f"{BASE_URL}/accounts/acc_1/refresh-token").mock( + return_value=httpx.Response(200, json={"expires_at": "2026-11-18T12:00:00.000Z"}) + ) + + assert client.accounts.toggle_primary("acc_1").is_primary is True + assert primary.called + + check = client.accounts.validate_credentials("acc_1") + assert validate.called + assert check.valid is False and check.health_status == "expired" + + assert client.accounts.refresh_token("acc_1").expires_at is not None + assert refresh.called diff --git a/tests/test_analytics.py b/tests/test_analytics.py index da06a5e..85e72b9 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -256,3 +256,156 @@ def test_native_posts_returns_a_page(client: Fopost) -> None: assert page[0].permalink == "https://x.com/acme/status/1" assert page[0].metrics.engagements == 90 assert page.meta.total == 1 + + +@respx.mock +def test_dashboard_reads_send_filters_and_decode(client: Fopost) -> None: + overview = respx.get(f"{BASE_URL}/analytics/overview").mock( + return_value=httpx.Response( + 200, + json={ + "total_accounts": 2, + "total_followers": 1500, + "deltas": {"followers": 3.5}, + "today_stats": {"posts": 1, "follower_change": 4, "engagement": 20}, + "platforms": [{"platform": "twitter", "accounts": 1, "followers": 900}], + "accounts": [ + { + "account_id": "acc_1", + "platform": "twitter", + "username": "yourbrand", + "name": "Your Brand", + "followers": 900, + "history": [{"date": "2026-10-18", "followers": 900}], + } + ], + }, + ) + ) + series = respx.get(f"{BASE_URL}/analytics/time-series").mock( + return_value=httpx.Response( + 200, json={"days": 7, "series": [{"date": "2026-10-18", "engagements": 5}]} + ) + ) + top = respx.get(f"{BASE_URL}/analytics/top-posts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "rank": 1, + "post_id": "post_1", + "source": "fopost", + "metrics": {"engagements": 50}, + "labels": [{"id": "lbl_1", "name": "Launch", "color": "#000"}], + } + ] + }, + ) + ) + labels = respx.get(f"{BASE_URL}/analytics/labels").mock( + return_value=httpx.Response( + 200, + json={"data": [{"label_id": "lbl_1", "name": "Launch", "post_count": 2}]}, + ) + ) + table = respx.get(f"{BASE_URL}/analytics/posts-table").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "post_id": "post_1", + "status": "published", + "platforms": [{"platform": "twitter", "delivery_status": "published"}], + "delivery_summary": {"total": 1, "published": 1}, + } + ], + "meta": {"page": 1, "per_page": 20, "total": 1, "total_pages": 1}, + "summary": {"published": 1, "total": 1}, + }, + ) + ) + streak = respx.get(f"{BASE_URL}/analytics/posting-streak").mock( + return_value=httpx.Response( + 200, json={"streak": [{"date": "2026-10-18", "count": 2, "published_count": 2}]} + ) + ) + demographics = respx.get(f"{BASE_URL}/analytics/demographics").mock( + return_value=httpx.Response( + 200, + json={ + "audience": "engaged", + "dimensions": {"age": [{"key": "25-34", "value": 40, "share": 0.4}]}, + "contributing_accounts": [{"account_id": "acc_1", "platform": "instagram"}], + "unsupported_accounts": [], + }, + ) + ) + inbox = respx.get(f"{BASE_URL}/analytics/inbox").mock( + return_value=httpx.Response( + 200, + json={ + "totals": {"received": 10, "sent": 8, "median_response_minutes": 12.5}, + "heatmap": [{"weekday": 1, "hour": 9, "count": 3}], + "time_zone": "Europe/Zagreb", + }, + ) + ) + collect = respx.post(f"{BASE_URL}/analytics/collect").mock( + return_value=httpx.Response( + 200, + json={ + "accounts": 2, + "posts": 5, + "demographics": 1, + "errors": 1, + "error_details": [ + {"account_id": "acc_2", "stage": "account", "action": "reconnect"} + ], + }, + ) + ) + + o = client.analytics.overview(days=30, workspace_id="ws_1") + assert o.total_followers == 1500 and o.accounts[0].history[0].followers == 900 + assert dict(overview.calls.last.request.url.params) == {"days": "30", "workspace_id": "ws_1"} + + ts = client.analytics.time_series(from_="2026-10-01", to="2026-10-18") + assert ts.series[0].engagements == 5 + assert dict(series.calls.last.request.url.params) == {"from": "2026-10-01", "to": "2026-10-18"} + + posts = client.analytics.top_posts(limit=1, sort="recent", label="Launch") + assert posts[0].metrics is not None and posts[0].metrics.engagements == 50 + assert dict(top.calls.last.request.url.params) == { + "limit": "1", + "sort": "recent", + "label": "Launch", + } + + assert client.analytics.labels(days=7)[0].post_count == 2 and labels.called + + rows = client.analytics.posts_table(account_id="acc_1") + assert rows.items[0].platforms[0].delivery_status == "published" + assert rows.meta.total == 1 and rows.summary is not None and rows.summary.published == 1 + assert dict(table.calls.last.request.url.params) == { + "page": "1", + "per_page": "20", + "account_id": "acc_1", + } + + assert client.analytics.posting_streak(workspace_id="ws_1")[0].published_count == 2 + assert streak.called + + d = client.analytics.demographics(audience="engaged") + assert d.dimensions.age[0].share == 0.4 and d.contributing_accounts[0].platform == "instagram" + assert dict(demographics.calls.last.request.url.params) == {"audience": "engaged"} + + ia = client.analytics.inbox(days=7, tz="Europe/Zagreb") + assert ia.totals is not None and ia.totals.median_response_minutes == 12.5 + assert ia.heatmap[0].hour == 9 + assert dict(inbox.calls.last.request.url.params) == {"days": "7", "tz": "Europe/Zagreb"} + + c = client.analytics.collect(workspace_id="ws_1") + assert c.errors == 1 and c.error_details[0].action == "reconnect" + assert dict(collect.calls.last.request.url.params) == {"workspace_id": "ws_1"} diff --git a/tests/test_automations.py b/tests/test_automations.py new file mode 100644 index 0000000..d06abac --- /dev/null +++ b/tests/test_automations.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +GRAPH = { + "nodes": [ + {"id": "t", "kind": "trigger", "label": None, "config": {}, "position": 0}, + {"id": "p", "kind": "publish", "label": "Post", "config": {"accounts": ["acc_1"]}}, + ], + "edges": [{"id": "e1", "source": "t", "source_handle": None, "target": "p"}], +} + +AUTOMATION = { + "id": "auto_1", + "workspace_id": "ws_1", + "name": "Cross-post", + "description": None, + "trigger_type": "content_created", + "trigger_config": {"account_id": "acc_1"}, + "active": True, + "schedule_window": None, + "max_per_day": None, + "credit_ceiling": None, + "approval_mode": False, + "last_triggered_at": None, + "run_count": 3, + "graph": GRAPH, + "steps": [{"position": 0, "node_id": "p", "action_type": "publish", "action_config": {}}], + "created_at": "2026-10-18T12:00:00.000Z", + "updated_at": "2026-10-18T12:00:00.000Z", +} + + +@respx.mock +def test_crud_toggle_and_stats(client: Fopost) -> None: + listed = respx.get(f"{BASE_URL}/automations").mock( + return_value=httpx.Response(200, json={"data": [AUTOMATION]}) + ) + created = respx.post(f"{BASE_URL}/automations").mock( + return_value=httpx.Response(201, json={**AUTOMATION, "secret": "asec_1"}) + ) + respx.get(f"{BASE_URL}/automations/auto_1").mock( + return_value=httpx.Response(200, json=AUTOMATION) + ) + updated = respx.put(f"{BASE_URL}/automations/auto_1").mock( + return_value=httpx.Response(200, json={**AUTOMATION, "name": "Renamed"}) + ) + deleted = respx.delete(f"{BASE_URL}/automations/auto_1").mock(return_value=httpx.Response(204)) + toggled = respx.post(f"{BASE_URL}/automations/auto_1/toggle").mock( + return_value=httpx.Response(200, json={"id": "auto_1", "active": False}) + ) + stats = respx.get(f"{BASE_URL}/automations/stats").mock( + return_value=httpx.Response( + 200, + json={ + "total_automations": 1, + "active_automations": 1, + "total_runs_24h": 2, + "runs_by_status": {"completed": 2}, + "recent_runs": [ + { + "id": 7, + "automation_id": "auto_1", + "status": "completed", + "started_at": "2026-10-18T12:00:00.000Z", + "completed_at": None, + } + ], + }, + ) + ) + + assert [a.id for a in client.automations.list()] == ["auto_1"] + assert listed.called + + auto = client.automations.create( + workspace_id="ws_1", + name="Cross-post", + trigger_type="content_created", + trigger_config={"account_id": "acc_1"}, + graph=GRAPH, + ) + assert auto.secret == "asec_1" + assert auto.graph is not None and auto.graph.edges[0].target == "p" + sent = json.loads(created.calls.last.request.content) + assert sent["workspaceId"] == "ws_1" and sent["triggerType"] == "content_created" + assert sent["triggerConfig"] == {"account_id": "acc_1"} + assert sent["graph"]["edges"] == [ + {"id": "e1", "source": "t", "sourceHandle": None, "target": "p"} + ] + + assert client.automations.get("auto_1").run_count == 3 + + renamed = client.automations.update( + "auto_1", name="Renamed", steps=[{"action_type": "publish", "action_config": {}}] + ) + assert renamed.name == "Renamed" + assert json.loads(updated.calls.last.request.content) == { + "name": "Renamed", + "steps": [{"actionType": "publish", "actionConfig": {}}], + } + + client.automations.delete("auto_1") + assert deleted.called + assert client.automations.toggle("auto_1").active is False and toggled.called + + totals = client.automations.stats() + assert stats.called + assert totals.runs_by_status == {"completed": 2} + assert totals.recent_runs[0].id == 7 + + +@respx.mock +def test_versions_are_listed_read_and_restored(client: Fopost) -> None: + listed = respx.get(f"{BASE_URL}/automations/auto_1/versions").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "version": 2, + "note": None, + "created_by": {"name": "Ops"}, + "created_at": "2026-10-18T12:00:00.000Z", + "node_count": 2, + "nodes_added": [{"id": "p", "kind": "publish", "label": "Post"}], + "nodes_removed": [], + } + ], + "meta": {"page": 1, "per_page": 30, "total": 1, "total_pages": 1}, + }, + ) + ) + respx.get(f"{BASE_URL}/automations/auto_1/versions/2").mock( + return_value=httpx.Response( + 200, + json={ + "version": 2, + "note": None, + "created_by": None, + "created_at": "2026-10-18T12:00:00.000Z", + "graph": GRAPH, + }, + ) + ) + restored = respx.post(f"{BASE_URL}/automations/auto_1/versions/2/restore").mock( + return_value=httpx.Response(201, json={"version": 3, "graph": GRAPH, "steps": []}) + ) + + versions = client.automations.versions("auto_1", page=1, per_page=30) + assert versions[0].version == 2 + assert versions[0].created_by is not None and versions[0].created_by.name == "Ops" + assert versions[0].nodes_added[0].kind == "publish" + assert dict(listed.calls.last.request.url.params) == {"page": "1", "per_page": "30"} + + one = client.automations.get_version("auto_1", 2) + assert one.graph is not None and [n.id for n in one.graph.nodes] == ["t", "p"] + + result = client.automations.restore_version("auto_1", 2) + assert restored.called and result.version == 3 + + +@respx.mock +def test_runs_page_and_single_run_with_logs(client: Fopost) -> None: + respx.get(f"{BASE_URL}/automations/auto_1/runs").mock( + return_value=httpx.Response( + 200, + json={ + "data": [{"id": 7, "status": "completed", "started_at": "2026-10-18T12:00:00Z"}], + "meta": {"page": 1, "per_page": 30, "total": 1, "total_pages": 1}, + }, + ) + ) + respx.get(f"{BASE_URL}/automations/auto_1/runs/7").mock( + return_value=httpx.Response( + 200, + json={ + "id": 7, + "automation_id": "auto_1", + "status": "completed", + "started_at": "2026-10-18T12:00:00Z", + "logs": [ + { + "id": 1, + "step_position": 0, + "node_id": "p", + "status": "completed", + "started_at": "2026-10-18T12:00:00Z", + "duration_ms": 40, + } + ], + }, + ) + ) + + runs, meta = client.automations.runs("auto_1") + assert runs[0].id == 7 and meta.total == 1 + + run = client.automations.get_run("auto_1", 7) + assert run.logs[0].node_id == "p" and run.logs[0].duration_ms == 40 diff --git a/tests/test_communities.py b/tests/test_communities.py new file mode 100644 index 0000000..d36198f --- /dev/null +++ b/tests/test_communities.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +COMMUNITY = { + "id": 5, + "account_id": "acc_1", + "community_id": "1700000000000000000", + "name": "Builders", + "member_count": 1200, + "description": None, + "image_url": None, + "last_synced_at": "2026-10-18T12:00:00.000Z", + "created_at": "2026-10-18T12:00:00.000Z", +} + + +@respx.mock +def test_communities_list_sync_search_add_and_remove(client: Fopost) -> None: + listed = respx.get(f"{BASE_URL}/accounts/acc_1/communities").mock( + return_value=httpx.Response(200, json={"data": [COMMUNITY]}) + ) + synced = respx.post(f"{BASE_URL}/accounts/acc_1/communities/sync").mock( + return_value=httpx.Response(200, json={"data": [COMMUNITY], "note": "stored list"}) + ) + searched = respx.get(f"{BASE_URL}/accounts/acc_1/communities/search").mock( + return_value=httpx.Response( + 200, + json={"data": [{"id": "17", "name": "Makers", "description": "", "member_count": 9}]}, + ) + ) + added = respx.post(f"{BASE_URL}/accounts/acc_1/communities/manual").mock( + return_value=httpx.Response(201, json=COMMUNITY) + ) + removed = respx.delete(f"{BASE_URL}/accounts/acc_1/communities/5").mock( + return_value=httpx.Response(204) + ) + + assert client.accounts.list_communities("acc_1")[0].community_id == "1700000000000000000" + assert listed.called + assert client.accounts.sync_communities("acc_1")[0].name == "Builders" and synced.called + + found = client.accounts.search_communities("acc_1", "makers") + assert found[0].id == "17" and found[0].member_count == 9 + assert dict(searched.calls.last.request.url.params) == {"q": "makers"} + + community = client.accounts.add_community("acc_1", community_id="1700000000000000000") + assert community.id == 5 + assert json.loads(added.calls.last.request.content) == {"communityId": "1700000000000000000"} + + client.accounts.remove_community("acc_1", 5) + assert removed.called diff --git a/tests/test_connect_sessions.py b/tests/test_connect_sessions.py new file mode 100644 index 0000000..f4c75ef --- /dev/null +++ b/tests/test_connect_sessions.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +SESSION = { + "id": "cs_1", + "status": "pending", + "external_user_id": "user-42", + "platforms": ["twitter", "linkedin"], + "redirect_url": "https://yourbrand.com/connected", + "branding": {"name": "Your Brand", "logo_url": "https://yourbrand.com/logo.png"}, + "account_id": None, + "url": "https://connect.fopost.com/s/cs_1", + "expires_at": "2026-10-18T13:00:00.000Z", + "used_at": None, + "created_at": "2026-10-18T12:00:00.000Z", +} + + +@respx.mock +def test_create_sends_camel_case_body_and_get_polls(client: Fopost) -> None: + create = respx.post(f"{BASE_URL}/connect-sessions").mock( + return_value=httpx.Response(201, json=SESSION) + ) + get = respx.get(f"{BASE_URL}/connect-sessions/cs_1").mock( + return_value=httpx.Response( + 200, json={"data": {**SESSION, "status": "used", "account_id": "acc_9"}} + ) + ) + + session = client.connect_sessions.create( + workspace_id="ws_1", + external_user_id="user-42", + platforms=["twitter", "linkedin"], + redirect_url="https://yourbrand.com/connected", + branding={"name": "Your Brand", "logo_url": "https://yourbrand.com/logo.png"}, + ) + + assert json.loads(create.calls.last.request.content) == { + "workspaceId": "ws_1", + "externalUserId": "user-42", + "platforms": ["twitter", "linkedin"], + "redirectUrl": "https://yourbrand.com/connected", + "branding": {"name": "Your Brand", "logoUrl": "https://yourbrand.com/logo.png"}, + } + assert session.id == "cs_1" and session.status == "pending" + assert session.url == "https://connect.fopost.com/s/cs_1" + assert session.branding is not None and session.branding.logo_url.endswith("logo.png") + + polled = client.connect_sessions.get("cs_1") + assert get.called + assert polled.status == "used" and polled.account_id == "acc_9" diff --git a/tests/test_labels.py b/tests/test_labels.py index 87125ec..51bfea3 100644 --- a/tests/test_labels.py +++ b/tests/test_labels.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import httpx import respx @@ -31,3 +33,36 @@ def test_list_without_a_workspace_returns_every_label(client: Fopost) -> None: assert client.labels.list() == [] assert dict(route.calls.last.request.url.params) == {} + + +@respx.mock +def test_create_get_update_and_delete(client: Fopost) -> None: + created = respx.post(f"{BASE_URL}/labels").mock( + return_value=httpx.Response(201, json=LABEL_FIXTURE) + ) + respx.get(f"{BASE_URL}/labels/lbl_1").mock( + return_value=httpx.Response(200, json={"data": LABEL_FIXTURE}) + ) + updated = respx.put(f"{BASE_URL}/labels/lbl_1").mock( + return_value=httpx.Response(200, json={**LABEL_FIXTURE, "name": "Renamed"}) + ) + deleted = respx.delete(f"{BASE_URL}/labels/lbl_1").mock(return_value=httpx.Response(204)) + + label = client.labels.create(workspace_id="ws_1", name="Launch", color="#4F46E5") + assert label.id == "lbl_1" + assert json.loads(created.calls.last.request.content) == { + "workspace_id": "ws_1", + "name": "Launch", + "color": "#4F46E5", + } + + assert client.labels.get("lbl_1").name == "Launch" + + assert client.labels.update("lbl_1", name="Renamed", color="#000000").name == "Renamed" + assert json.loads(updated.calls.last.request.content) == { + "name": "Renamed", + "color": "#000000", + } + + client.labels.delete("lbl_1") + assert deleted.called diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..aa9dbf9 --- /dev/null +++ b/tests/test_logs.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + + +@respx.mock +def test_list_sends_filters_and_decodes_cursor_and_summary(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/logs").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": 42, + "workspace_id": "ws_1", + "time": "2026-10-18T12:00:00.000Z", + "auth_method": "api_key", + "method": "POST", + "route": "/v1/posts", + "path": "/v1/posts", + "status_code": 500, + "duration_ms": 80, + "platform": None, + "message": "boom", + } + ], + "meta": {"next_cursor": "41", "has_more": True}, + "summary": { + "since": "2026-10-11T12:00:00.000Z", + "total": 10, + "errors": 1, + "buckets": [{"at": "2026-10-18T12:00:00.000Z", "total": 3, "errors": 1}], + }, + }, + ) + ) + + page = client.logs.list(workspace_id="ws_1", status="failed", days=7, limit=1, before=43) + + assert dict(route.calls.last.request.url.params) == { + "workspace_id": "ws_1", + "status": "failed", + "days": "7", + "limit": "1", + "before": "43", + } + assert page.items[0].id == 42 and page.items[0].status_code == 500 + assert page.meta.next_cursor == "41" and page.meta.has_more is True + assert page.summary is not None and page.summary.buckets[0].errors == 1 diff --git a/tests/test_media.py b/tests/test_media.py index 825c7c4..8f44361 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -224,3 +224,40 @@ def test_a_413_surfaces_as_file_too_large_with_the_ceiling(client: Fopost) -> No assert error.file_bytes == 209715200 assert error.upgrade_url == "/settings/billing" assert error.body["max_file_bytes"] == 104857600 + + +@respx.mock +def test_list_upload_and_delete(client: Fopost) -> None: + listed = respx.get(f"{BASE_URL}/media").mock( + return_value=httpx.Response( + 200, + json={ + "data": [{**MEDIA_FIXTURE, "workspace_id": "ws_1", "tags": ["chart"]}], + "meta": {"current_page": 1, "per_page": 60, "total": 1, "last_page": 1}, + }, + ) + ) + uploaded = respx.post(f"{BASE_URL}/media/upload").mock( + return_value=httpx.Response(201, json={"data": [MEDIA_FIXTURE]}) + ) + deleted = respx.delete(f"{BASE_URL}/media/med_1").mock(return_value=httpx.Response(204)) + + page = client.media.list(workspace_id="ws_1", type="image", shared=False) + assert page[0].tags == ["chart"] and page.meta.total == 1 + assert dict(listed.calls.last.request.url.params) == { + "workspace_id": "ws_1", + "type": "image", + "shared": "false", + "page": "1", + "per_page": "60", + } + + items = client.media.upload([("chart.png", b"\x89PNG", "image/png")], workspace_id="ws_1") + assert items[0].id == "med_1" + request = uploaded.calls.last.request + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="workspaceId"\r\n\r\nws_1' in request.content + assert b'name="files"; filename="chart.png"' in request.content + + client.media.delete("med_1") + assert deleted.called diff --git a/tests/test_posts.py b/tests/test_posts.py index 778ac76..46b6106 100644 --- a/tests/test_posts.py +++ b/tests/test_posts.py @@ -245,3 +245,83 @@ def test_create_with_an_account_group(client: Fopost) -> None: body = json.loads(route.calls.last.request.content) assert body["account_group_id"] == "grp_1" assert body["accounts"] == [] + + +@respx.mock +def test_unpublish_reads_per_delivery_outcome(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/posts/post_1/unpublish").mock( + return_value=httpx.Response( + 200, + json={ + "post_status": "unpublished", + "deliveries": [{"account_id": "acc_1", "platform": "twitter", "removed": True}], + }, + ) + ) + + result = client.posts.unpublish("post_1") + + assert route.called + assert result.post_status == "unpublished" + assert result.deliveries[0].removed is True + + +@respx.mock +def test_bulk_import_validate_commit_and_rollback(client: Fopost) -> None: + validate = respx.post(f"{BASE_URL}/posts/bulk-import/validate").mock( + return_value=httpx.Response( + 200, + json={ + "total_rows": 1, + "valid_rows": 1, + "invalid_rows": 0, + "max_rows": 500, + "rows": [{"row": 1, "content_preview": "Hi", "accounts": ["acc_1"], "errors": []}], + }, + ) + ) + commit = respx.post(f"{BASE_URL}/posts/bulk-import/commit").mock( + return_value=httpx.Response( + 201, + json={ + "batch_id": "batch_1", + "created": 1, + "held_for_approval": False, + "posts": [{"id": "post_9", "schedule_at": "2026-10-20T09:00:00.000Z"}], + }, + ) + ) + rollback = respx.delete(f"{BASE_URL}/posts/bulk-import/batch_1").mock( + return_value=httpx.Response(200, json={"deleted": 1}) + ) + csv = ("posts.csv", b"content,schedule_at\nHi,2026-10-20 09:00\n", "text/csv") + + checked = client.posts.validate_bulk_import(workspace_id="ws_1", file=csv) + assert checked.valid_rows == 1 and checked.rows[0].accounts == ["acc_1"] + request = validate.calls.last.request + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="workspace_id"\r\n\r\nws_1' in request.content + assert b'filename="posts.csv"' in request.content + + done = client.posts.commit_bulk_import(workspace_id="ws_1", file=csv) + assert commit.called and done.batch_id == "batch_1" and done.posts[0].id == "post_9" + + assert client.posts.rollback_bulk_import("batch_1").deleted == 1 + assert rollback.called + + +@respx.mock +def test_posts_create_and_update_accept_schedule_queue(client: Fopost) -> None: + create = respx.post(f"{BASE_URL}/posts").mock( + return_value=httpx.Response(201, json=POST_FIXTURE) + ) + update = respx.put(f"{BASE_URL}/posts/post_1").mock( + return_value=httpx.Response(200, json=POST_FIXTURE) + ) + + client.posts.create(workspace_id="ws_1", content="Hi", accounts=["acc_1"], schedule="queue") + assert json.loads(create.calls.last.request.content)["schedule"] == "queue" + assert "schedule" not in json.loads(create.calls.last.request.content).get("settings", {}) + + client.posts.update("post_1", schedule="queue") + assert json.loads(update.calls.last.request.content) == {"schedule": "queue"} diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..7625fa0 --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +SLOT = { + "id": "slot_1", + "workspace_id": "ws_1", + "account_id": None, + "weekday": 1, + "time": "09:00", + "timezone": "Europe/Zagreb", + "created_at": "2026-10-01T00:00:00.000Z", +} + + +@respx.mock +def test_slots_crud_sends_bodies_and_reads_moved_posts(client: Fopost) -> None: + listed = respx.get(f"{BASE_URL}/queue/slots").mock( + return_value=httpx.Response(200, json={"data": [SLOT]}) + ) + created = respx.post(f"{BASE_URL}/queue/slots").mock( + return_value=httpx.Response(201, json=SLOT) + ) + updated = respx.put(f"{BASE_URL}/queue/slots/slot_1").mock( + return_value=httpx.Response( + 200, + json={ + **SLOT, + "time": "10:00", + "moved": [ + { + "id": "post_1", + "from": "2026-10-05T09:00:00.000Z", + "to": "2026-10-05T10:00:00.000Z", + } + ], + }, + ) + ) + deleted = respx.delete(f"{BASE_URL}/queue/slots/slot_1").mock( + return_value=httpx.Response(200, json={"deleted": True, "moved": []}) + ) + + slots = client.queue.list_slots(workspace_id="ws_1") + assert [s.id for s in slots] == ["slot_1"] + assert dict(listed.calls.last.request.url.params) == {"workspace_id": "ws_1"} + + slot = client.queue.create_slot( + workspace_id="ws_1", weekday=1, time="09:00", timezone="Europe/Zagreb" + ) + assert slot.weekday == 1 and slot.account_id is None + assert json.loads(created.calls.last.request.content) == { + "workspace_id": "ws_1", + "weekday": 1, + "time": "09:00", + "timezone": "Europe/Zagreb", + } + + change = client.queue.update_slot("slot_1", time="10:00") + assert json.loads(updated.calls.last.request.content) == {"time": "10:00"} + assert change.time == "10:00" + assert change.moved[0].from_ == "2026-10-05T09:00:00.000Z" + assert change.moved[0].to == "2026-10-05T10:00:00.000Z" + + gone = client.queue.delete_slot("slot_1") + assert deleted.called and gone.deleted is True and gone.moved == [] + + +@respx.mock +def test_next_and_preview_decode_times(client: Fopost) -> None: + nxt = respx.get(f"{BASE_URL}/queue/next").mock( + return_value=httpx.Response(200, json={"next": "2026-10-05T09:00:00.000Z"}) + ) + preview = respx.get(f"{BASE_URL}/queue/preview").mock( + return_value=httpx.Response( + 200, json={"data": ["2026-10-05T09:00:00.000Z", "2026-10-06T09:00:00.000Z"]} + ) + ) + + assert client.queue.next(workspace_id="ws_1", account_id="acc_1") == datetime( + 2026, 10, 5, 9, tzinfo=timezone.utc + ) + assert dict(nxt.calls.last.request.url.params) == { + "workspace_id": "ws_1", + "account_id": "acc_1", + } + + times = client.queue.preview(workspace_id="ws_1", count=2) + assert times == [ + datetime(2026, 10, 5, 9, tzinfo=timezone.utc), + datetime(2026, 10, 6, 9, tzinfo=timezone.utc), + ] + assert dict(preview.calls.last.request.url.params) == {"workspace_id": "ws_1", "count": "2"} diff --git a/tests/test_webhook_endpoints.py b/tests/test_webhook_endpoints.py new file mode 100644 index 0000000..28ac033 --- /dev/null +++ b/tests/test_webhook_endpoints.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +ENDPOINT = { + "id": "wh_1", + "workspace_id": "ws_1", + "name": "Prod", + "url": "https://yourbrand.com/hooks/fopost", + "events": ["post.published"], + "header_names": ["X-Env"], + "active": True, + "last_triggered_at": None, + "failure_count": 0, + "created_at": "2026-10-18T12:00:00.000Z", +} + +DELIVERY = { + "id": "del_1", + "webhook_id": "wh_1", + "event": "post.published", + "delivery_key": "post.published:post_1", + "attempt": 1, + "status": "failed", + "response_status": 500, + "response_snippet": "boom", + "duration_ms": 120, + "payload_hash": "abc", + "next_retry_at": None, + "created_at": "2026-10-18T12:00:00.000Z", +} + + +@respx.mock +def test_crud_and_test_send_the_expected_bodies(client: Fopost) -> None: + listed = respx.get(f"{BASE_URL}/webhooks").mock( + return_value=httpx.Response(200, json={"data": [ENDPOINT]}) + ) + created = respx.post(f"{BASE_URL}/webhooks").mock( + return_value=httpx.Response(201, json={**ENDPOINT, "secret": "whsec_1"}) + ) + updated = respx.put(f"{BASE_URL}/webhooks/wh_1").mock( + return_value=httpx.Response(200, json={"data": {**ENDPOINT, "active": False}}) + ) + deleted = respx.delete(f"{BASE_URL}/webhooks/wh_1").mock(return_value=httpx.Response(204)) + tested = respx.post(f"{BASE_URL}/webhooks/wh_1/test").mock( + return_value=httpx.Response(200, json={"message": "sent"}) + ) + + assert [w.id for w in client.webhook_endpoints.list()] == ["wh_1"] + assert listed.called + + endpoint = client.webhook_endpoints.create( + workspace_id="ws_1", + url="https://yourbrand.com/hooks/fopost", + events=["post.published"], + name="Prod", + headers={"X-Env": "prod"}, + ) + assert endpoint.secret == "whsec_1" + assert endpoint.header_names == ["X-Env"] + assert json.loads(created.calls.last.request.content) == { + "workspaceId": "ws_1", + "url": "https://yourbrand.com/hooks/fopost", + "events": ["post.published"], + "name": "Prod", + "headers": {"X-Env": "prod"}, + } + + paused = client.webhook_endpoints.update("wh_1", active=False, headers=None) + assert paused.active is False + assert json.loads(updated.calls.last.request.content) == {"active": False, "headers": None} + + client.webhook_endpoints.delete("wh_1") + client.webhook_endpoints.test("wh_1") + assert deleted.called and tested.called + + +@respx.mock +def test_deliveries_are_listed_read_and_redelivered(client: Fopost) -> None: + listed = respx.get(f"{BASE_URL}/webhooks/wh_1/deliveries").mock( + return_value=httpx.Response(200, json={"data": [DELIVERY]}) + ) + respx.get(f"{BASE_URL}/webhooks/wh_1/deliveries/del_1").mock( + return_value=httpx.Response(200, json={**DELIVERY, "payload": {"event": "post.published"}}) + ) + redelivered = respx.post(f"{BASE_URL}/webhooks/wh_1/deliveries/del_1/redeliver").mock( + return_value=httpx.Response(200, json={"message": "queued"}) + ) + + deliveries = client.webhook_endpoints.deliveries("wh_1", status="failed", limit=10) + assert deliveries[0].response_status == 500 and deliveries[0].payload is None + assert dict(listed.calls.last.request.url.params) == {"status": "failed", "limit": "10"} + + one = client.webhook_endpoints.get_delivery("wh_1", "del_1") + assert one.payload == {"event": "post.published"} + + client.webhook_endpoints.redeliver("wh_1", "del_1") + assert redelivered.called diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py index ffad096..258b474 100644 --- a/tests/test_workspaces.py +++ b/tests/test_workspaces.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timezone import httpx @@ -37,3 +38,43 @@ def test_get_returns_one_workspace(client: Fopost) -> None: ) assert client.workspaces.get("ws_1").name == "Acme" + + +@respx.mock +def test_create_update_and_delete(client: Fopost) -> None: + created = respx.post(f"{BASE_URL}/workspaces").mock( + return_value=httpx.Response(201, json=WORKSPACE_FIXTURE) + ) + updated = respx.put(f"{BASE_URL}/workspaces/ws_1").mock( + return_value=httpx.Response( + 200, + json={ + **WORKSPACE_FIXTURE, + "name": "Acme Co", + "connect_redirect_domains": ["yourbrand.com"], + }, + ) + ) + deleted = respx.delete(f"{BASE_URL}/workspaces/ws_1").mock(return_value=httpx.Response(204)) + + ws = client.workspaces.create(name="Acme", type="BRAND", timezone="UTC") + assert ws.id == "ws_1" + assert json.loads(created.calls.last.request.content) == { + "name": "Acme", + "type": "BRAND", + "timezone": "UTC", + } + + changed = client.workspaces.update( + "ws_1", name="Acme Co", require_approval=True, connect_redirect_domains=["yourbrand.com"] + ) + assert changed.name == "Acme Co" + assert changed.connect_redirect_domains == ["yourbrand.com"] + assert json.loads(updated.calls.last.request.content) == { + "name": "Acme Co", + "requireApproval": True, + "connectRedirectDomains": ["yourbrand.com"], + } + + client.workspaces.delete("ws_1") + assert deleted.called