Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## Unreleased

### Added

- `client.blog`: settings, keyword planning and articles under `/v1/blog`, with the
`BlogSettings`, `BlogKeyword`, `BlogArticle`, `BlogArticleFull` and `BlogArticleImage` models.
- `fopost.webhooks.verify` (also exported as `verify_webhook`): verifies any FoPost webhook
delivery over the raw body and returns the `{event, data, timestamp}` envelope, with the
typed `ArticlePublishedPayload` for `article.published`.

### Changed

- `ChatAdapter.verify_webhook` now runs on the shared `fopost.webhooks` verifier. Behaviour and
error codes are unchanged.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,44 @@ repurposed = client.ai.repurpose_url(
> `401` to an API key. They are here so the surface is complete once the server
> opens them up.

## Blog

`client.blog` plans keywords, writes articles and publishes them to a website. A post goes
to a social network; an article goes to a website.

```python
article = client.blog.request_article(
workspace.id,
keyword="best trail running shoes",
instructions="Mention the return policy.",
)
print(article.status) # "queued"; it becomes a draft once written

for draft in client.blog.list_articles(workspace.id, status="draft"):
client.blog.publish_article(workspace.id, draft.id)
```

With the Website target, every published article is POSTed to your `website_url` as a signed
webhook. `verify_webhook` checks the signature over the raw body and returns the envelope:

```python
from fopost import ArticlePublishedPayload, verify_webhook


@app.post("/webhooks/fopost")
async def inbound(request: Request) -> Response:
secret = os.environ["FOPOST_WEBHOOK_SECRET"]
event = verify_webhook(await request.body(), request.headers, secret)
if event.event == "article.published":
article = ArticlePublishedPayload.model_validate(event.data)
save_article(article.slug, article.body_html)
return Response(status_code=204)
```

The secret is returned once, when `update_settings(..., website_url=...)` first saves the URL;
`rotate_website_secret` issues a new one. A forged, stale or unsigned delivery raises
`WebhookVerificationError` with a `code`.

## Analytics

```python
Expand Down Expand Up @@ -251,6 +289,7 @@ except FopostError as err:
| `knowledge` | `list`, `create`, `update`, `delete`, `sync`, `search` |
| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` |
| `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` |
| `blog` | `get_settings`, `update_settings`, `rotate_website_secret`, `test_website`, `list_keywords`, `plan_keywords`, `add_keyword`, `update_keyword`, `delete_keyword`, `list_articles`, `request_article`, `get_article`, `update_article`, `regenerate_article`, `publish_article`, `reject_article`, `delete_article` |
| `validate` | `post`, `length`, `media` |
| `analytics` | `decay`, `frequency`, `timeline`, `changes`, `collect_post`, `native_posts` |
| `activity` | `list` |
Expand Down
35 changes: 35 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

from . import webhooks
from ._http import DEFAULT_BASE_URL
from .client import Fopost
from .errors import (
Expand All @@ -29,6 +30,9 @@
RateLimitError,
)
from .models import (
BLOG_ARTICLE_STATUSES,
BLOG_KEYWORD_STATUSES,
BLOG_TARGET_KINDS,
PLATFORMS,
POST_STATUSES,
AccountGroup,
Expand Down Expand Up @@ -57,10 +61,21 @@
AdSource,
AiCreditBalance,
AiCredits,
ArticlePublishedPayload,
Audience,
AudienceFilter,
AudiencesResult,
BidPricing,
BlogArticle,
BlogArticleFull,
BlogArticleImage,
BlogArticleStatus,
BlogKeyword,
BlogKeywordStatus,
BlogSettings,
BlogSettingsUpdate,
BlogTargetKind,
BlogWebsiteTest,
BlueskyLanguages,
BoostablePost,
Broadcast,
Expand Down Expand Up @@ -208,6 +223,8 @@
YouTubePlaylist,
YouTubeTranscript,
)
from .webhooks import WebhookEvent, WebhookVerificationError
from .webhooks import verify as verify_webhook

try:
__version__ = _pkg_version("fopost")
Expand Down Expand Up @@ -423,4 +440,22 @@
"ConversionRule",
"ReachEstimate",
"SupplyForecast",
"BLOG_ARTICLE_STATUSES",
"BLOG_KEYWORD_STATUSES",
"BLOG_TARGET_KINDS",
"ArticlePublishedPayload",
"BlogArticle",
"BlogArticleFull",
"BlogArticleImage",
"BlogArticleStatus",
"BlogKeyword",
"BlogKeywordStatus",
"BlogSettings",
"BlogSettingsUpdate",
"BlogTargetKind",
"BlogWebsiteTest",
"WebhookEvent",
"WebhookVerificationError",
"verify_webhook",
"webhooks",
]
66 changes: 11 additions & 55 deletions src/fopost/chat_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@
from __future__ import annotations

import builtins
import hashlib
import hmac
import json
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any

from .client import Fopost
from .models import InboxAttachment, InboxItem
from .webhooks import (
SIGNATURE_TOLERANCE_SECONDS,
WebhookVerificationError,
header,
verify_signature,
)

__all__ = [
"INBOUND_EVENT",
Expand All @@ -42,12 +45,6 @@
#: The webhook event a new direct message raises.
INBOUND_EVENT = "inbox.message_received"

#: Refuse a delivery signed longer ago than this. Matches the API's own tolerance.
SIGNATURE_TOLERANCE_SECONDS = 300

_SIGNATURE_HEADER = "x-fopost-signature"
_TIMESTAMPED_SIGNATURE_HEADER = "x-fopost-signature-256"
_TIMESTAMP_HEADER = "x-fopost-timestamp"
_EVENT_HEADER = "x-fopost-event"


Expand Down Expand Up @@ -126,26 +123,6 @@ def to_chat_message(item: InboxItem) -> ChatMessage:
)


def _header(headers: Mapping[str, Any], name: str) -> str | None:
getter = getattr(headers, "get", None)
if getter is not None:
value = getter(name)
if value is None:
value = getter(name.title())
if value is not None:
return value[0] if isinstance(value, (list, tuple)) else str(value)
for key, value in dict(headers).items():
if key.lower() == name:
return value[0] if isinstance(value, (list, tuple)) else str(value)
return None


def _digest(value: str | None) -> str | None:
if value is None:
return None
return value[7:] if value.startswith("sha256=") else value


class ChatAdapter:
"""Send and receive direct messages through one FoPost workspace."""

Expand Down Expand Up @@ -182,7 +159,7 @@ def parse_webhook(self, body: str | bytes, headers: Mapping[str, Any]) -> ChatEv
if not isinstance(envelope, dict):
raise ChatAdapterError("invalid_body", "Webhook body is not an object")

event = envelope.get("event") or _header(headers, _EVENT_HEADER)
event = envelope.get("event") or header(headers, _EVENT_HEADER)
if event != INBOUND_EVENT:
raise ChatAdapterError(
"unexpected_event", f"Expected {INBOUND_EVENT}, got {event or 'nothing'}"
Expand All @@ -207,31 +184,10 @@ def verify_webhook(self, body: str | bytes, headers: Mapping[str, Any]) -> None:
"""Raise ``ChatAdapterError`` when the delivery is unsigned, forged or stale."""
if not self._webhook_secret:
raise ChatAdapterError("missing_secret", "Pass webhook_secret to verify a delivery")

raw = body.encode() if isinstance(body, str) else body
secret = self._webhook_secret.encode()

timestamped = _digest(_header(headers, _TIMESTAMPED_SIGNATURE_HEADER))
if timestamped is not None:
try:
sent_at = int(_header(headers, _TIMESTAMP_HEADER) or "")
except ValueError as err:
raise ChatAdapterError(
"invalid_signature", "Signed delivery carries no timestamp"
) from err
age = abs(time.time() - sent_at)
if age > SIGNATURE_TOLERANCE_SECONDS:
raise ChatAdapterError("stale_delivery", f"Delivery is {int(age)}s old")
expected = hmac.new(secret, f"{sent_at}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(timestamped, expected):
raise ChatAdapterError("invalid_signature", "Signature does not match the body")
return

plain = _digest(_header(headers, _SIGNATURE_HEADER))
if plain is None:
raise ChatAdapterError("invalid_signature", "Delivery carries no signature header")
if not hmac.compare_digest(plain, hmac.new(secret, raw, hashlib.sha256).hexdigest()):
raise ChatAdapterError("invalid_signature", "Signature does not match the body")
try:
verify_signature(body, headers, self._webhook_secret)
except WebhookVerificationError as err:
raise ChatAdapterError(err.code, err.message) from err

def receive(
self,
Expand Down
2 changes: 2 additions & 0 deletions src/fopost/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AdsResource,
AiResource,
AnalyticsResource,
BlogResource,
BroadcastsResource,
ContactsResource,
GoogleBusinessResource,
Expand Down Expand Up @@ -87,6 +88,7 @@ def __init__(
self.knowledge = KnowledgeResource(self._http)
self.activity = ActivityResource(self._http)
self.google_business = GoogleBusinessResource(self._http)
self.blog = BlogResource(self._http)

@property
def base_url(self) -> str:
Expand Down
Loading
Loading