feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s

#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
2026-05-03 18:30:38 +05:30
parent 59d398abc3
commit eeb684b46c
86 changed files with 20349 additions and 1655 deletions

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import os
from typing import Any
import httpx
class ColonyConfigurationError(RuntimeError):
pass
class ColonyGatewayError(RuntimeError):
pass
class ColonyGateway:
"""HTTP client from the Python root to the internal colony orchestrator."""
def __init__(self, *, base_url: str | None = None, timeout_s: float | None = None) -> None:
resolved_base_url = (base_url or os.getenv("COLONY_SERVICE_URL", "")).strip().rstrip("/")
if not resolved_base_url:
raise ColonyConfigurationError("COLONY_SERVICE_URL is not configured.")
self.base_url = resolved_base_url
self.timeout = httpx.Timeout(timeout_s or float(os.getenv("COLONY_TIMEOUT_SECONDS", "30")))
async def health(self) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
response = await client.get(f"{self.base_url}/health")
if response.status_code >= 400:
raise ColonyGatewayError(f"Colony service health check failed with HTTP {response.status_code}.")
return response.json()
async def dispatch_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(f"{self.base_url}/missions", json=mission)
if response.status_code >= 400:
raise ColonyGatewayError(
f"Colony service rejected mission with HTTP {response.status_code}: {response.text}"
)
return response.json()
async def mission_status(self, mission_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/missions/{mission_id}/status")
if response.status_code >= 400:
raise ColonyGatewayError(
f"Colony service status check failed with HTTP {response.status_code}: {response.text}"
)
return response.json()

View File

@@ -0,0 +1,253 @@
from __future__ import annotations
import json
from typing import Any
import asyncpg
_JSON_KEYS = {
"context_refs",
"requested_outputs",
"payload",
"input",
"output",
"citations",
"before_state",
"after_state",
"detail",
}
def _row_dict(row: asyncpg.Record | None) -> dict[str, Any] | None:
if row is None:
return None
data = dict(row)
for key in _JSON_KEYS:
if isinstance(data.get(key), str):
data[key] = json.loads(data[key])
return data
class ColonyRepository:
def __init__(self, pool: asyncpg.Pool) -> None:
self.pool = pool
async def create_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO colony_missions (
mission_id, tenant_id, mission_type, origin_surface, actor_id,
actor_role, risk_level, sensitivity_class, status, review_status,
time_budget_ms, token_budget, user_goal, normalized_goal,
context_refs, requested_outputs, payload, created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5,
$6, $7, $8, 'pending', NULL,
$9, $10, $11, $12,
$13::jsonb, $14::jsonb, $15::jsonb, NOW(), NOW()
)
RETURNING *
""",
mission["mission_id"],
mission["tenant_id"],
mission["mission_type"],
mission["origin_surface"],
mission["actor_id"],
mission.get("actor_role"),
mission["risk_level"],
mission["sensitivity_class"],
mission["time_budget_ms"],
mission["token_budget"],
mission["user_goal"],
mission["normalized_goal"],
json.dumps(mission["context_refs"]),
json.dumps(mission["requested_outputs"]),
json.dumps(mission["payload"]),
)
return _row_dict(row)
async def get_mission(self, mission_id: str, tenant_id: str) -> dict[str, Any] | None:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT *
FROM colony_missions
WHERE mission_id = $1::uuid
AND tenant_id = $2
""",
mission_id,
tenant_id,
)
return _row_dict(row)
async def update_status(
self,
mission_id: str,
tenant_id: str,
status: str,
*,
review_status: str | None = None,
) -> dict[str, Any] | None:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE colony_missions
SET status = $3,
review_status = COALESCE($4, review_status),
updated_at = NOW(),
completed_at = CASE
WHEN $3 IN ('completed', 'failed', 'dispatch_failed') THEN NOW()
ELSE completed_at
END
WHERE mission_id = $1::uuid
AND tenant_id = $2
RETURNING *
""",
mission_id,
tenant_id,
status,
review_status,
)
return _row_dict(row)
async def list_missions(self, tenant_id: str, *, limit: int, offset: int) -> list[dict[str, Any]]:
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT *
FROM colony_missions
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
tenant_id,
limit,
offset,
)
return [_row_dict(row) for row in rows]
async def artifacts(self, mission_id: str, tenant_id: str) -> dict[str, list[dict[str, Any]]]:
async with self.pool.acquire() as conn:
mission = await conn.fetchrow(
"SELECT mission_id FROM colony_missions WHERE mission_id = $1::uuid AND tenant_id = $2",
mission_id,
tenant_id,
)
if mission is None:
return {}
tasks = await conn.fetch(
"""
SELECT *
FROM colony_tasks
WHERE mission_id = $1::uuid
AND tenant_id = $2
ORDER BY created_at ASC
""",
mission_id,
tenant_id,
)
results = await conn.fetch(
"""
SELECT *
FROM colony_worker_results
WHERE mission_id = $1::uuid
AND tenant_id = $2
ORDER BY created_at ASC
""",
mission_id,
tenant_id,
)
proposals = await conn.fetch(
"""
SELECT *
FROM colony_writeback_proposals
WHERE mission_id = $1::uuid
AND tenant_id = $2
ORDER BY created_at DESC
""",
mission_id,
tenant_id,
)
return {
"tasks": [_row_dict(row) for row in tasks],
"worker_results": [_row_dict(row) for row in results],
"writeback_proposals": [_row_dict(row) for row in proposals],
}
async def pending_writeback_proposals(self, mission_id: str, tenant_id: str) -> list[dict[str, Any]]:
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT *
FROM colony_writeback_proposals
WHERE mission_id = $1::uuid
AND tenant_id = $2
AND approval_status = 'pending'
ORDER BY created_at ASC
""",
mission_id,
tenant_id,
)
return [_row_dict(row) for row in rows]
async def approve_pending_writebacks(self, mission_id: str, tenant_id: str, actor_id: str) -> int:
async with self.pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE colony_writeback_proposals
SET approval_status = 'approved',
approved_by = $3,
approved_at = NOW()
WHERE mission_id = $1::uuid
AND tenant_id = $2
AND approval_status = 'pending'
""",
mission_id,
tenant_id,
actor_id,
)
return int(result.rsplit(" ", 1)[-1])
async def reject_pending_writebacks(self, mission_id: str, tenant_id: str, actor_id: str, reason: str) -> int:
async with self.pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE colony_writeback_proposals
SET approval_status = 'rejected',
rejected_by = $3,
rejected_at = NOW(),
rejection_reason = $4
WHERE mission_id = $1::uuid
AND tenant_id = $2
AND approval_status = 'pending'
""",
mission_id,
tenant_id,
actor_id,
reason,
)
return int(result.rsplit(" ", 1)[-1])
async def log_event(
self,
*,
mission_id: str | None,
tenant_id: str,
event_type: str,
actor: str | None,
detail: dict[str, Any] | None = None,
) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO colony_event_log (mission_id, tenant_id, event_type, actor, detail, created_at)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb, NOW())
""",
mission_id,
tenant_id,
event_type,
actor,
json.dumps(detail or {}),
)

View File

@@ -6,9 +6,12 @@ import json
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID
import httpx
PHONEUTILS_AVAILABLE = False
try:
import phonenumbers
@@ -22,6 +25,130 @@ except ImportError:
DEFAULT_COUNTRY = os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91")
class TranscriptionError(RuntimeError):
"""Raised when the configured transcription provider cannot produce text."""
async def _read_recording_bytes(recording_url: str) -> tuple[bytes, str, str]:
if not recording_url:
raise TranscriptionError("recording_url is required.")
if recording_url.startswith("file://"):
path = Path(recording_url[7:]).expanduser()
return path.read_bytes(), path.name or "recording.audio", "application/octet-stream"
local_path = Path(recording_url).expanduser()
if local_path.exists():
return local_path.read_bytes(), local_path.name or "recording.audio", "application/octet-stream"
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
response = await client.get(recording_url)
response.raise_for_status()
content_type = response.headers.get("content-type", "application/octet-stream")
filename = recording_url.rstrip("/").split("/")[-1] or "recording.audio"
return response.content, filename, content_type
async def _transcribe_openai(recording_url: str) -> dict[str, Any]:
api_key = os.getenv("OPENAI_API_KEY", "").strip()
if not api_key:
raise TranscriptionError("OPENAI_API_KEY is required for COMMS_TRANSCRIPTION_PROVIDER=openai.")
audio, filename, content_type = await _read_recording_bytes(recording_url)
model = os.getenv("COMMS_OPENAI_TRANSCRIPTION_MODEL", "whisper-1")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
"https://api.openai.com/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {api_key}"},
data={"model": model, "response_format": "verbose_json"},
files={"file": (filename, audio, content_type)},
)
response.raise_for_status()
payload = response.json()
text = (payload.get("text") or "").strip()
if not text:
raise TranscriptionError("OpenAI transcription response did not include text.")
return {
"text": text,
"provider": "openai",
"language": payload.get("language") or "unknown",
"segments": payload.get("segments") or [],
"raw": payload,
}
async def _transcribe_deepgram(recording_url: str) -> dict[str, Any]:
api_key = os.getenv("DEEPGRAM_API_KEY", "").strip()
if not api_key:
raise TranscriptionError("DEEPGRAM_API_KEY is required for COMMS_TRANSCRIPTION_PROVIDER=deepgram.")
audio, _, content_type = await _read_recording_bytes(recording_url)
model = os.getenv("COMMS_DEEPGRAM_MODEL", "nova-2")
language = os.getenv("COMMS_TRANSCRIPTION_LANGUAGE", "en")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"https://api.deepgram.com/v1/listen?model={model}&language={language}&diarize=true&smart_format=true",
headers={"Authorization": f"Token {api_key}", "Content-Type": content_type},
content=audio,
)
response.raise_for_status()
payload = response.json()
alternative = (
payload.get("results", {})
.get("channels", [{}])[0]
.get("alternatives", [{}])[0]
)
text = (alternative.get("transcript") or "").strip()
if not text:
raise TranscriptionError("Deepgram transcription response did not include text.")
words = alternative.get("words") or []
return {
"text": text,
"provider": "deepgram",
"language": language,
"segments": words,
"raw": payload,
}
async def _transcribe_http_endpoint(recording_url: str) -> dict[str, Any]:
endpoint = os.getenv("COMMS_TRANSCRIPTION_ENDPOINT", "").strip()
if not endpoint:
raise TranscriptionError("COMMS_TRANSCRIPTION_ENDPOINT is required for COMMS_TRANSCRIPTION_PROVIDER=http.")
token = os.getenv("COMMS_TRANSCRIPTION_ENDPOINT_TOKEN", "").strip()
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(endpoint, json={"recording_url": recording_url}, headers=headers)
response.raise_for_status()
payload = response.json()
text = (payload.get("text") or payload.get("transcript") or "").strip()
if not text:
raise TranscriptionError("HTTP transcription endpoint response did not include text.")
return {
"text": text,
"provider": "http",
"language": payload.get("language") or "unknown",
"segments": payload.get("segments") or [],
"raw": payload,
}
async def transcribe_recording(recording_url: str, provider: str | None = None) -> dict[str, Any]:
selected = (provider or os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none")).strip().lower()
try:
if selected in {"", "none", "disabled"}:
raise TranscriptionError("COMMS_TRANSCRIPTION_PROVIDER is not configured.")
if selected in {"openai", "whisper"}:
return await _transcribe_openai(recording_url)
if selected == "deepgram":
return await _transcribe_deepgram(recording_url)
if selected in {"http", "endpoint", "custom"}:
return await _transcribe_http_endpoint(recording_url)
raise TranscriptionError(f"Unsupported COMMS_TRANSCRIPTION_PROVIDER '{selected}'.")
except httpx.HTTPError as exc:
raise TranscriptionError(f"{selected} transcription request failed: {exc}") from exc
def normalize_phone(phone: str, default_region: str = DEFAULT_COUNTRY) -> str | None:
"""Return an E.164-like phone number suitable for provider and CRM matching."""
if not phone:

View File

@@ -0,0 +1,508 @@
from __future__ import annotations
import json
import os
import uuid
from datetime import datetime, timezone
from enum import Enum
from typing import Any
import asyncpg
import httpx
from pydantic import BaseModel, Field
class SocialPostingConfigurationError(RuntimeError):
pass
class SocialPostingError(RuntimeError):
pass
class SocialPlatform(str, Enum):
FACEBOOK = "facebook"
INSTAGRAM = "instagram"
LINKEDIN = "linkedin"
TWITTER = "twitter"
class PostStatus(str, Enum):
SCHEDULED = "scheduled"
PUBLISHING = "publishing"
PUBLISHED = "published"
FAILED = "failed"
class PostType(str, Enum):
IMAGE = "image"
VIDEO = "video"
CAROUSEL = "carousel"
TEXT = "text"
LINK = "link"
class PostRequest(BaseModel):
platforms: list[SocialPlatform] = Field(..., min_length=1, max_length=8)
post_type: PostType = PostType.IMAGE
caption: str = Field(..., min_length=1, max_length=4000)
hashtags: list[str] = Field(default_factory=list, max_length=40)
media_url: str | None = Field(default=None, max_length=2048)
media_path: str | None = Field(default=None, max_length=2048)
link_url: str | None = Field(default=None, max_length=2048)
schedule_time: str | None = Field(default=None, description="ISO-8601 timestamp. Future timestamps persist as scheduled.")
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value or value.startswith("PLACEHOLDER"):
raise SocialPostingConfigurationError(f"{name} is not configured.")
return value
def _meta_version() -> str:
return os.getenv("META_API_VERSION", "v21.0").strip() or "v21.0"
def _parse_schedule(value: str | None) -> datetime | None:
if not value:
return None
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _caption_with_hashtags(caption: str, hashtags: list[str]) -> str:
cleaned = [tag.strip() for tag in hashtags if tag.strip()]
return f"{caption}\n\n{' '.join(cleaned)}" if cleaned else caption
def _serialize_row(row: asyncpg.Record | dict[str, Any]) -> dict[str, Any]:
value = dict(row)
for key in ("hashtags", "engagement", "platform_response"):
if isinstance(value.get(key), str):
value[key] = json.loads(value[key])
for key in ("created_at", "scheduled_at", "published_at", "updated_at"):
if value.get(key) is not None and hasattr(value[key], "isoformat"):
value[key] = value[key].isoformat()
return value
def _required_env_for_platform(platform: SocialPlatform) -> tuple[str, ...]:
if platform == SocialPlatform.FACEBOOK:
return ("META_PAGE_ACCESS_TOKEN", "META_PAGE_ID")
if platform == SocialPlatform.INSTAGRAM:
return ("META_PAGE_ACCESS_TOKEN", "META_INSTAGRAM_BUSINESS_ID")
if platform == SocialPlatform.LINKEDIN:
return ("LINKEDIN_ACCESS_TOKEN", "LINKEDIN_ORG_ID")
if platform == SocialPlatform.TWITTER:
return ("TWITTER_BEARER_TOKEN",)
raise SocialPostingConfigurationError(f"Unsupported social platform: {platform.value}")
def validate_platform_configuration(platforms: list[SocialPlatform]) -> None:
missing: list[str] = []
for platform in platforms:
for name in _required_env_for_platform(platform):
value = os.getenv(name, "").strip()
if not value or value.startswith("PLACEHOLDER"):
missing.append(name)
if missing:
joined = ", ".join(sorted(set(missing)))
raise SocialPostingConfigurationError(f"Social posting credentials are not configured: {joined}.")
def validate_payload_contract(payload: PostRequest) -> None:
if SocialPlatform.INSTAGRAM in payload.platforms and not payload.media_url:
raise SocialPostingError("media_url is required for Instagram publishing.")
if payload.post_type == PostType.VIDEO and not payload.media_url:
raise SocialPostingError("media_url is required for video publishing.")
if SocialPlatform.TWITTER in payload.platforms:
text = _caption_with_hashtags(payload.caption, payload.hashtags)
if payload.link_url:
text = f"{text}\n{payload.link_url}"
if payload.media_url:
text = f"{text}\n{payload.media_url}"
if len(text) > 280:
raise SocialPostingError("Twitter/X post exceeds 280 characters after links and hashtags.")
async def _insert_post(
conn: asyncpg.Connection,
*,
tenant_id: str,
actor_id: str,
request_id: str,
platform: SocialPlatform,
payload: PostRequest,
status: PostStatus,
scheduled_at: datetime | None,
) -> dict[str, Any]:
row = await conn.fetchrow(
"""
INSERT INTO catalyst_social_posts (
post_id, request_id, tenant_id, actor_id, platform, post_type,
caption, hashtags, media_url, media_path, link_url, status,
scheduled_at, engagement, created_at, updated_at
) VALUES (
$1::uuid, $2::uuid, $3, $4, $5, $6,
$7, $8::jsonb, $9, $10, $11, $12,
$13, '{}'::jsonb, NOW(), NOW()
)
RETURNING *
""",
str(uuid.uuid4()),
request_id,
tenant_id,
actor_id,
platform.value,
payload.post_type.value,
payload.caption,
json.dumps(payload.hashtags),
payload.media_url,
payload.media_path,
payload.link_url,
status.value,
scheduled_at,
)
return _serialize_row(row)
async def _update_post(
conn: asyncpg.Connection,
*,
post_id: str,
tenant_id: str,
status: PostStatus,
platform_post_id: str | None = None,
platform_response: dict[str, Any] | None = None,
error: str | None = None,
) -> dict[str, Any]:
row = await conn.fetchrow(
"""
UPDATE catalyst_social_posts
SET status = $3,
platform_post_id = COALESCE($4, platform_post_id),
platform_response = COALESCE($5::jsonb, platform_response),
error = $6,
published_at = CASE WHEN $3 = 'published' THEN NOW() ELSE published_at END,
updated_at = NOW()
WHERE post_id = $1::uuid
AND tenant_id = $2
RETURNING *
""",
post_id,
tenant_id,
status.value,
platform_post_id,
json.dumps(platform_response) if platform_response is not None else None,
error,
)
if row is None:
raise SocialPostingError(f"Social post '{post_id}' not found.")
return _serialize_row(row)
class FacebookPublisher:
base = "https://graph.facebook.com"
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
token = _env("META_PAGE_ACCESS_TOKEN")
page_id = _env("META_PAGE_ID")
caption = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
post_type = post["post_type"]
async with httpx.AsyncClient(timeout=120.0) as client:
if post_type == PostType.VIDEO.value:
if not post.get("media_url"):
raise SocialPostingError("media_url is required for Facebook video posts.")
response = await client.post(
f"{self.base}/{_meta_version()}/{page_id}/videos",
data={"file_url": post["media_url"], "description": caption, "access_token": token},
)
elif post_type in {PostType.IMAGE.value, PostType.CAROUSEL.value} and post.get("media_url"):
response = await client.post(
f"{self.base}/{_meta_version()}/{page_id}/photos",
data={"url": post["media_url"], "message": caption, "access_token": token},
)
else:
message = f"{caption}\n{post['link_url']}" if post.get("link_url") else caption
response = await client.post(
f"{self.base}/{_meta_version()}/{page_id}/feed",
data={"message": message, "access_token": token},
)
if response.status_code >= 400:
raise SocialPostingError(f"Facebook publish failed: {response.text}")
data = response.json()
return data.get("id", data.get("post_id", "")), data
class InstagramPublisher:
base = "https://graph.facebook.com"
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
token = _env("META_PAGE_ACCESS_TOKEN")
instagram_id = _env("META_INSTAGRAM_BUSINESS_ID")
if not post.get("media_url"):
raise SocialPostingError("media_url is required for Instagram posts.")
caption = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
async with httpx.AsyncClient(timeout=180.0) as client:
create_payload: dict[str, Any] = {
"caption": caption,
"access_token": token,
}
if post["post_type"] == PostType.VIDEO.value:
create_payload.update({"video_url": post["media_url"], "media_type": "REELS"})
else:
create_payload["image_url"] = post["media_url"]
created = await client.post(
f"{self.base}/{_meta_version()}/{instagram_id}/media",
data=create_payload,
)
if created.status_code >= 400:
raise SocialPostingError(f"Instagram container creation failed: {created.text}")
container_id = created.json().get("id")
if not container_id:
raise SocialPostingError("Instagram did not return a media container id.")
published = await client.post(
f"{self.base}/{_meta_version()}/{instagram_id}/media_publish",
data={"creation_id": container_id, "access_token": token},
)
if published.status_code >= 400:
raise SocialPostingError(f"Instagram publish failed: {published.text}")
data = published.json()
return data.get("id", ""), {"container": created.json(), "publish": data}
class LinkedInPublisher:
base = "https://api.linkedin.com/v2"
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
token = _env("LINKEDIN_ACCESS_TOKEN")
org_id = _env("LINKEDIN_ORG_ID")
caption = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
if post.get("link_url"):
caption = f"{caption}\n{post['link_url']}"
if post.get("media_url"):
caption = f"{caption}\n{post['media_url']}"
payload = {
"author": f"urn:li:organization:{org_id}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": caption},
"shareMediaCategory": "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{self.base}/ugcPosts",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
},
json=payload,
)
if response.status_code >= 400:
raise SocialPostingError(f"LinkedIn publish failed: {response.text}")
data = response.json()
return data.get("id", ""), data
class TwitterPublisher:
base = "https://api.twitter.com/2"
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
token = _env("TWITTER_BEARER_TOKEN")
text = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
if post.get("link_url"):
text = f"{text}\n{post['link_url']}"
if post.get("media_url"):
text = f"{text}\n{post['media_url']}"
if len(text) > 280:
raise SocialPostingError("Twitter/X post exceeds 280 characters after links and hashtags.")
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{self.base}/tweets",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"text": text},
)
if response.status_code >= 400:
raise SocialPostingError(f"Twitter/X publish failed: {response.text}")
data = response.json()
return data.get("data", {}).get("id", ""), data
_PUBLISHERS = {
SocialPlatform.FACEBOOK.value: FacebookPublisher(),
SocialPlatform.INSTAGRAM.value: InstagramPublisher(),
SocialPlatform.LINKEDIN.value: LinkedInPublisher(),
SocialPlatform.TWITTER.value: TwitterPublisher(),
}
async def _publish_post(conn: asyncpg.Connection, post: dict[str, Any]) -> dict[str, Any]:
publishing = await _update_post(
conn,
post_id=post["post_id"],
tenant_id=post["tenant_id"],
status=PostStatus.PUBLISHING,
)
publisher = _PUBLISHERS.get(publishing["platform"])
if publisher is None:
raise SocialPostingError(f"Unsupported social platform: {publishing['platform']}")
platform_post_id, platform_response = await publisher.publish(publishing)
return await _update_post(
conn,
post_id=publishing["post_id"],
tenant_id=publishing["tenant_id"],
status=PostStatus.PUBLISHED,
platform_post_id=platform_post_id,
platform_response=platform_response,
)
async def publish_content(
*,
pool: asyncpg.Pool,
tenant_id: str,
actor_id: str,
payload: PostRequest,
) -> dict[str, Any]:
validate_platform_configuration(payload.platforms)
validate_payload_contract(payload)
request_id = str(uuid.uuid4())
scheduled_at = _parse_schedule(payload.schedule_time)
posts: list[dict[str, Any]] = []
async with pool.acquire() as conn:
async with conn.transaction():
for platform in payload.platforms:
post = await _insert_post(
conn,
tenant_id=tenant_id,
actor_id=actor_id,
request_id=request_id,
platform=platform,
payload=payload,
status=PostStatus.SCHEDULED if scheduled_at and scheduled_at > _utcnow() else PostStatus.PUBLISHING,
scheduled_at=scheduled_at,
)
posts.append(post)
if scheduled_at and scheduled_at > _utcnow():
return {
"request_id": request_id,
"total": len(posts),
"published": 0,
"scheduled": len(posts),
"failed": 0,
"posts": posts,
}
published: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
async with pool.acquire() as conn:
for post in posts:
try:
published.append(await _publish_post(conn, post))
except (SocialPostingConfigurationError, SocialPostingError, httpx.HTTPError) as exc:
failed.append(
await _update_post(
conn,
post_id=post["post_id"],
tenant_id=tenant_id,
status=PostStatus.FAILED,
error=str(exc),
)
)
return {
"request_id": request_id,
"total": len(posts),
"published": len(published),
"scheduled": 0,
"failed": len(failed),
"posts": published + failed,
}
async def get_post(*, pool: asyncpg.Pool, tenant_id: str, post_id: str) -> dict[str, Any] | None:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM catalyst_social_posts WHERE post_id = $1::uuid AND tenant_id = $2",
post_id,
tenant_id,
)
return _serialize_row(row) if row else None
async def list_posts(
*,
pool: asyncpg.Pool,
tenant_id: str,
platform: SocialPlatform | None = None,
status: PostStatus | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
clauses = ["tenant_id = $1"]
params: list[Any] = [tenant_id]
if platform:
params.append(platform.value)
clauses.append(f"platform = ${len(params)}")
if status:
params.append(status.value)
clauses.append(f"status = ${len(params)}")
params.append(limit)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT *
FROM catalyst_social_posts
WHERE {' AND '.join(clauses)}
ORDER BY created_at DESC
LIMIT ${len(params)}
""",
*params,
)
return [_serialize_row(row) for row in rows]
async def publish_due_scheduled(*, pool: asyncpg.Pool, tenant_id: str, limit: int = 20) -> dict[str, Any]:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT *
FROM catalyst_social_posts
WHERE tenant_id = $1
AND status = 'scheduled'
AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC
LIMIT $2
""",
tenant_id,
limit,
)
published: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
for row in rows:
post = _serialize_row(row)
try:
published.append(await _publish_post(conn, post))
except (SocialPostingConfigurationError, SocialPostingError, httpx.HTTPError) as exc:
failed.append(
await _update_post(
conn,
post_id=post["post_id"],
tenant_id=tenant_id,
status=PostStatus.FAILED,
error=str(exc),
)
)
return {"published": len(published), "failed": len(failed), "posts": published + failed}