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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,10 @@ STUDIO_BASE_URL=
# MCP_PUBLIC_BASE_URL=https://your-studio.example.com
# MCP_OAUTH_ISSUER_URL=https://your-studio.example.com


# Optional OpenAI inbox reply extension (see apps/inbox_ai/README.md)
INBOX_AI_ENABLED=false
OPENAI_API_KEY=
INBOX_AI_MODEL=gpt-4.1-mini
# Optional absolute path to a custom JSON prompt/style configuration
# INBOX_AI_PROMPTS_FILE=/path/to/inbox-reply-prompts.json
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,10 @@ No developer app registration needed. Users connect by entering a personal **API

Posts publish as DEV.to articles (title + Markdown body). The key can be revoked at any time from the same settings page.

## Inbox: Optional AI Reply Generator

Add an **AI reply** button with configurable reply styles, language matching (including Sinhala), and article context using the OpenAI API. It is an optional Django extension with minimal core hooks for reapplying after upstream upgrades. See the [extension setup and upgrade guide](apps/inbox_ai/README.md).

## Inbox: Backfill Historical Messages

See the [Supported Platforms](#supported-platforms) matrix above for per-platform inbox capabilities.
Expand Down Expand Up @@ -626,7 +630,7 @@ Issue an API key from **Organization → API Keys**. Keys are workspace-scoped,
Authorization: Bearer bb_studio_...
```

Permission keys: `create_posts`, `publish_directly`, `upload_media`, `view_analytics`. Each endpoint requires the relevant permission; missing permissions return `403`.
Permission keys: `create_posts`, `publish_directly`, `upload_media`, `view_analytics`, `use_inbox`, `reply_from_inbox`. Each endpoint requires the relevant permission; missing permissions return `403`.

### Rate Limits

Expand Down Expand Up @@ -654,9 +658,17 @@ Rate-limit responses (`429`) include `Retry-After`, `X-RateLimit-Limit`, and `X-
| `POST` | `/media` | Upload a media file (multipart) | `upload_media` |
| `GET` | `/media/{media_id}` | Retrieve a media asset | — |
| `GET` | `/media` | List media assets (filter, paginate) | — |
| `GET` | `/inbox` | List inbox messages (filter by status/type/account, paginate) | `use_inbox` |
| `GET` | `/inbox/{message_id}` | Read one inbox message with its reply thread | `use_inbox` |
| `POST` | `/inbox/{message_id}/replies` | Draft a reply (set `send: true` to deliver it now) | `use_inbox` (+ `reply_from_inbox` to send) |
| `PATCH` | `/inbox/replies/{reply_id}` | Edit a draft reply | `use_inbox` |
| `POST` | `/inbox/replies/{reply_id}/send` | Deliver a draft reply to the platform | `reply_from_inbox` |
| `DELETE` | `/inbox/replies/{reply_id}` | Discard a draft reply | `use_inbox` |
| `POST` | `/mcp` | JSON-RPC 2.0 endpoint for MCP clients | — |

All write endpoints accept `idempotency_key` (or `Idempotency-Key` header) for safe retries.
Post creation, media uploads, and inbox reply creation accept `idempotency_key` (or `Idempotency-Key` header) for safe retries.

For inbox reply creation, reuse the same key and request to replay the original response without creating or sending another reply. A failed create-and-send response is replayed too; retrieve the retained failed reply from the message thread and retry delivery via `/inbox/replies/{reply_id}/send`.

### MCP Tools

Expand All @@ -676,6 +688,12 @@ The MCP server lives at `POST {APP_URL}/api/v1/mcp` and speaks JSON-RPC 2.0 over
| `upload_media` | Upload a small base64-encoded file (≤ 1 MB raw). For larger files, use REST `POST /media`. | `upload_media` |
| `get_account_analytics` | Channel analytics over a rolling 7–90 day window | `view_analytics` |
| `get_post_analytics` | Per-platform metrics for a single post (safe for polling drafts) | `view_analytics` |
| `list_inbox_messages` | List inbox items (comments, mentions, DMs, reviews) with their reply threads | `use_inbox` |
| `get_inbox_message` | Retrieve one inbox message and its reply thread | `use_inbox` |
| `create_reply_draft` | Draft a reply to an inbox message (saved, not sent) | `use_inbox` |
| `update_reply_draft` | Replace the body of a draft (or failed) reply | `use_inbox` |
| `discard_reply_draft` | Delete a draft (or failed) reply | `use_inbox` |
| `send_reply` | Deliver a reply (`reply_id`, or `message_id` + `body` to draft-and-send) | `reply_from_inbox` |

### Connecting an MCP client

Expand Down
14 changes: 14 additions & 0 deletions apps/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from apps.api.auth import ApiKeyAuth, McpAuth
from apps.api.routers.accounts import router as accounts_router
from apps.api.routers.analytics import router as analytics_router
from apps.api.routers.inbox import router as inbox_router
from apps.api.routers.me import router as me_router
from apps.api.routers.media import router as media_router
from apps.api.routers.posts import router as posts_router
Expand Down Expand Up @@ -76,6 +77,7 @@ class NoncedSwagger(Swagger):
api.add_router("/posts", posts_router)
api.add_router("/media", media_router)
api.add_router("/analytics", analytics_router)
api.add_router("/inbox", inbox_router)
# MCP Streamable HTTP transport. Same audit + rate limits as REST, but a
# wider auth class: ``McpAuth`` accepts both bb_studio_ keys AND OAuth 2.1
# access tokens (Claude Desktop's native connector flow). Mounted last so
Expand Down Expand Up @@ -233,6 +235,18 @@ def _action_for_path(method: str, path: str, *, status_code: int) -> str:
return f"media.upload.{status_code}"
if method == "GET":
return f"media.read.{status_code}"
if "/inbox/" in path or path.endswith("/inbox"):
if "/replies" in path or "/reply" in path:
if path.endswith("/send"):
return f"inbox.reply.send.{status_code}"
if method == "POST":
return f"inbox.reply.create.{status_code}"
if method == "PATCH":
return f"inbox.reply.update.{status_code}"
if method == "DELETE":
return f"inbox.reply.discard.{status_code}"
if method == "GET":
return f"inbox.read.{status_code}"
if "/mcp" in path:
return f"mcp.error.{status_code}"
if "/accounts" in path:
Expand Down
268 changes: 268 additions & 0 deletions apps/api/routers/inbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
"""``/api/v1/inbox/*`` — read inbox messages and draft / send replies.

The inbox equivalent of :mod:`apps.api.routers.posts`: every route is a
thin adapter over :mod:`apps.inbox.services`, the single source of truth
shared with the HTMX views and the MCP inbox tools. Message and reply
lookups are scoped to the key's workspace **and** its account allowlist,
returning 404 (never 403) for anything outside it so a partial-scope key
can't probe foreign IDs.

Permissions mirror the web inbox: ``use_inbox`` to read and to manage
drafts, ``reply_from_inbox`` to actually deliver a reply to the platform.
"""

from __future__ import annotations

import uuid

from django.db.models import QuerySet
from django.http import Http404, HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Query, Router
from ninja.errors import HttpError

from apps.api.limits import enforce_http_rate_limits
from apps.api.middleware import (
claim_idempotency_slot,
finalize_idempotent_response,
fingerprint_request,
log_audit_entry,
release_idempotent_claim,
)
from apps.api.pagination import decode_offset_cursor, encode_offset_cursor
from apps.api.schemas import (
CreateReplyRequest,
InboxMessageResponse,
InboxMessagesListResponse,
InboxReplyResponse,
UpdateReplyRequest,
)
from apps.inbox.models import InboxMessage, InboxReply
from apps.inbox.services import (
ReplyStateError,
create_reply_draft,
discard_reply_draft,
send_reply_now,
update_reply_draft,
)

router = Router(tags=["inbox"])

_LIMIT_DEFAULT = 50
_LIMIT_MAX = 100


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _require_perm(request: HttpRequest, key: str) -> None:
membership = getattr(request, "workspace_membership", None)
if membership is None or not membership.effective_permissions.get(key, False):
raise HttpError(403, f"Permission denied: {key}")


def _allowlisted_account_ids(request: HttpRequest) -> set[uuid.UUID]:
return {sa.id for sa in request.api_key.social_accounts.all()} # type: ignore[attr-defined]


def _visible_messages_qs(request: HttpRequest) -> QuerySet[InboxMessage]:
"""Messages in the key's workspace whose account is in the allowlist."""
return InboxMessage.objects.filter(
workspace_id=request.api_key.workspace_id, # type: ignore[attr-defined]
social_account_id__in=_allowlisted_account_ids(request),
).select_related("social_account")


def _get_message(request: HttpRequest, message_id: uuid.UUID) -> InboxMessage:
return get_object_or_404(_visible_messages_qs(request), id=message_id)


def _get_reply(request: HttpRequest, reply_id: uuid.UUID) -> InboxReply:
reply = get_object_or_404(
InboxReply.objects.select_related("inbox_message", "inbox_message__social_account", "author"),
id=reply_id,
inbox_message__workspace_id=request.api_key.workspace_id, # type: ignore[attr-defined]
)
if reply.inbox_message.social_account_id not in _allowlisted_account_ids(request):
raise Http404()
return reply


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------


@router.get("/", response=InboxMessagesListResponse, summary="List inbox messages")
def list_messages(
request,
status: str | None = Query(None),
message_type: str | None = Query(None),
social_account_id: uuid.UUID | None = Query(None),
limit: int = Query(_LIMIT_DEFAULT, ge=1, le=_LIMIT_MAX),
cursor: str | None = Query(None),
):
enforce_http_rate_limits(request, is_write=False)
_require_perm(request, "use_inbox")

if status is not None and status not in InboxMessage.Status.values:
raise HttpError(422, f"status must be one of {', '.join(InboxMessage.Status.values)}")
if message_type is not None and message_type not in InboxMessage.MessageType.values:
raise HttpError(422, f"message_type must be one of {', '.join(InboxMessage.MessageType.values)}")

try:
offset = decode_offset_cursor(cursor)
except ValueError as exc:
raise HttpError(422, "cursor is not a valid pagination cursor") from exc

qs = _visible_messages_qs(request).prefetch_related("replies__author")
if status:
qs = qs.filter(status=status)
if message_type:
qs = qs.filter(message_type=message_type)
if social_account_id is not None:
if social_account_id not in _allowlisted_account_ids(request):
raise HttpError(403, "social_account_id is not in this key's allowlist.")
qs = qs.filter(social_account_id=social_account_id)
qs = qs.order_by("-received_at", "id")

rows = list(qs[offset : offset + limit + 1])
has_more = len(rows) > limit
rows = rows[:limit]
log_audit_entry(request, action="inbox.list", target_id=None, status_code=200)
return InboxMessagesListResponse(
messages=[InboxMessageResponse.from_message(m, include_replies=True) for m in rows],
limit=limit,
next_cursor=encode_offset_cursor(offset + limit) if has_more else None,
)


@router.get("/{message_id}", response=InboxMessageResponse, summary="Read one inbox message")
def retrieve_message(request, message_id: uuid.UUID):
enforce_http_rate_limits(request, is_write=False)
_require_perm(request, "use_inbox")
message = _get_message(request, message_id)
log_audit_entry(request, action="inbox.read", target_id=message.id, status_code=200)
return InboxMessageResponse.from_message(message, include_replies=True)


@router.post(
"/{message_id}/replies",
response={201: InboxReplyResponse},
summary="Create a draft reply (optionally send it)",
)
def create_reply(request, message_id: uuid.UUID, payload: CreateReplyRequest):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "use_inbox")
if payload.send:
_require_perm(request, "reply_from_inbox")

message = _get_message(request, message_id)
idempotency_key = payload.idempotency_key or request.headers.get("Idempotency-Key") or None
fingerprint = fingerprint_request(request.method or "POST", request.path, payload.model_dump(mode="json"))
try:
disposition, replay_status, replay_body = claim_idempotency_slot(
api_key=request.api_key,
idempotency_key=idempotency_key,
fingerprint=fingerprint,
)
except ValueError as exc:
raise HttpError(422, str(exc)) from exc
if disposition == "replay":
assert replay_status is not None and replay_body is not None
if replay_status >= 400:
raise HttpError(replay_status, replay_body["detail"])
return replay_status, replay_body
if disposition == "in_flight":
raise HttpError(409, "An identical request with this idempotency_key is still in flight; retry shortly.")

try:
reply = create_reply_draft(
message=message,
body=payload.body,
author=request.user if not request.user.is_anonymous else None,
)
except ValueError as exc:
release_idempotent_claim(api_key=request.api_key, idempotency_key=idempotency_key)
raise HttpError(422, str(exc)) from exc
except Exception:
release_idempotent_claim(api_key=request.api_key, idempotency_key=idempotency_key)
raise

# Once a draft exists, retain the claim even on failure: replaying a
# create-and-send request must never create another reply or send twice.
try:
if payload.send:
try:
send_reply_now(reply, actor=request.user if not request.user.is_anonymous else None)
except NotImplementedError:
pass # provider has no reply API; the local draft is recorded as sent
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
except Exception as exc: # platform refused it — reply is left in "failed"
raise HttpError(502, f"Reply not sent: {reply.send_error or 'Please try again later.'}") from exc

body = InboxReplyResponse.from_reply(reply)
log_audit_entry(request, action="inbox.reply.create", target_id=reply.id, status_code=201)
finalize_idempotent_response(
api_key=request.api_key,
idempotency_key=idempotency_key,
status_code=201,
body=body.model_dump(mode="json"),
)
return 201, body
except HttpError as exc:
finalize_idempotent_response(
api_key=request.api_key,
idempotency_key=idempotency_key,
status_code=exc.status_code,
body={"detail": exc.message},
)
raise


@router.patch("/replies/{reply_id}", response=InboxReplyResponse, summary="Edit a draft reply")
def update_reply(request, reply_id: uuid.UUID, payload: UpdateReplyRequest):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "use_inbox")
reply = _get_reply(request, reply_id)
try:
update_reply_draft(reply, body=payload.body)
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
except ValueError as exc:
raise HttpError(422, str(exc)) from exc
log_audit_entry(request, action="inbox.reply.update", target_id=reply.id, status_code=200)
return InboxReplyResponse.from_reply(reply)


@router.post("/replies/{reply_id}/send", response=InboxReplyResponse, summary="Send a draft reply")
def send_reply(request, reply_id: uuid.UUID):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "reply_from_inbox")
reply = _get_reply(request, reply_id)
try:
send_reply_now(reply, actor=request.user if not request.user.is_anonymous else None)
except NotImplementedError:
pass
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
except Exception as exc:
raise HttpError(502, f"Reply not sent: {reply.send_error or 'Please try again later.'}") from exc
log_audit_entry(request, action="inbox.reply.send", target_id=reply.id, status_code=200)
return InboxReplyResponse.from_reply(reply)


@router.delete("/replies/{reply_id}", response={204: None}, summary="Discard a draft reply")
def delete_reply(request, reply_id: uuid.UUID):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "use_inbox")
reply = _get_reply(request, reply_id)
try:
discard_reply_draft(reply)
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
log_audit_entry(request, action="inbox.reply.discard", target_id=reply_id, status_code=204)
return 204, None
Loading
Loading