from __future__ import annotations import json from datetime import datetime from typing import Any from app.db import execute, fetch_all, fetch_one PLATFORMS = ("twitter", "linkedin", "instagram", "facebook", "tiktok", "pinterest") _REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { "twitter": ("api_key", "api_secret", "access_token", "access_secret"), "linkedin": ("access_token", "person_urn"), "instagram": ("access_token", "page_id"), "facebook": ("access_token", "page_id"), "tiktok": ("access_token", "open_id"), "pinterest": ("access_token", "board_id"), } def _normalize_platform(platform: str) -> str: value = (platform or "").strip().lower() if value not in PLATFORMS: raise ValueError(f"Unsupported platform: {platform}") return value def _serialize(value: Any) -> Any: if hasattr(value, "isoformat"): return value.isoformat() return value def _normalize_config(row: dict[str, Any] | None) -> dict[str, Any]: if not row: return {} config = row.get("config") or {} if isinstance(config, str): try: config = json.loads(config) except Exception: config = {} if not isinstance(config, dict): config = {} # Keep compatibility with schemas that store fields as columns. for key in ("api_key", "api_secret", "access_token", "access_secret", "person_urn", "page_id", "open_id", "board_id"): if row.get(key) and not config.get(key): config[key] = row.get(key) return config def _has_credentials(platform: str, config: dict[str, Any]) -> bool: required = _REQUIRED_FIELDS.get(platform, ()) if not required: return False return all(bool(config.get(name)) for name in required) def _log_event(title: str, body: str, status: str, metadata: dict[str, Any]) -> None: try: execute( """ INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) """, ( "marketing_automation", "social_publish", "social_publish", title, body[:2000], status, "marketing", json.dumps(metadata), ), ) except Exception: pass def get_integration(platform: str) -> dict[str, Any] | None: platform = _normalize_platform(platform) row = fetch_one( "SELECT * FROM social_integrations WHERE platform = %s AND COALESCE(is_active, TRUE) = TRUE", (platform,), ) if not row: return None out = {k: _serialize(v) for k, v in row.items()} out["platform"] = platform out["config"] = _normalize_config(row) return out def get_configured_channels() -> list[dict[str, Any]]: rows = fetch_all( "SELECT * FROM social_integrations WHERE platform = ANY(%s) ORDER BY platform", (list(PLATFORMS),), ) by_platform = {(row.get("platform") or "").lower(): row for row in rows} items: list[dict[str, Any]] = [] for platform in PLATFORMS: row = by_platform.get(platform) config = _normalize_config(row) items.append( { "platform": platform, "configured": _has_credentials(platform, config), "is_active": bool(row.get("is_active")) if row else False, "updated_at": _serialize(row.get("updated_at")) if row else None, } ) return items def publish_to_channel(platform: str, text: str, image_path: str | None = None, image_url: str | None = None) -> dict[str, Any]: try: platform = _normalize_platform(platform) except ValueError as exc: return {"status": "failed", "error": str(exc), "platform": platform} integration = get_integration(platform) if not integration: return { "status": "skipped_not_configured", "error": f"{platform} integration is not configured", "platform": platform, } config = integration.get("config") or {} if not _has_credentials(platform, config): return { "status": "skipped_not_configured", "error": f"Missing credentials for {platform}", "platform": platform, } try: if platform == "twitter": try: import tweepy # type: ignore except Exception as exc: return {"status": "failed_dependency", "platform": platform, "error": f"tweepy unavailable: {exc}"} client = tweepy.Client( consumer_key=config["api_key"], consumer_secret=config["api_secret"], access_token=config["access_token"], access_token_secret=config["access_secret"], ) resp = client.create_tweet(text=text[:280]) return {"status": "published", "platform": platform, "external_id": str(getattr(resp, "data", {}) or {})} if platform == "linkedin": import requests payload = { "author": config.get("person_urn"), "lifecycleState": "PUBLISHED", "specificContent": { "com.linkedin.ugc.ShareContent": { "shareCommentary": {"text": text}, "shareMediaCategory": "IMAGE" if image_url else "NONE", } }, "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}, } if image_url: payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [{"status": "READY", "originalUrl": image_url}] r = requests.post( "https://api.linkedin.com/v2/ugcPosts", headers={"Authorization": f"Bearer {config['access_token']}", "X-Restli-Protocol-Version": "2.0.0"}, json=payload, timeout=20, ) return {"status": "published" if r.ok else "failed", "platform": platform, "response_code": r.status_code, "error": None if r.ok else r.text[:300]} if platform in ("instagram", "facebook"): import requests endpoint = f"https://graph.facebook.com/v20.0/{config['page_id']}/feed" payload = {"message": text, "access_token": config["access_token"]} if image_url: endpoint = f"https://graph.facebook.com/v20.0/{config['page_id']}/photos" payload = {"url": image_url, "caption": text, "access_token": config["access_token"]} r = requests.post(endpoint, data=payload, timeout=20) data = {} try: data = r.json() except Exception: data = {} return { "status": "published" if r.ok else "failed", "platform": platform, "external_id": data.get("id"), "response_code": r.status_code, "error": None if r.ok else (data.get("error", {}).get("message") or r.text[:300]), } if platform == "pinterest": import requests payload = {"board_id": config.get("board_id"), "title": text[:100], "description": text, "media_source": {"source_type": "image_url", "url": image_url}} r = requests.post( "https://api.pinterest.com/v5/pins", headers={"Authorization": f"Bearer {config['access_token']}", "Content-Type": "application/json"}, json=payload, timeout=20, ) return {"status": "published" if r.ok else "failed", "platform": platform, "response_code": r.status_code, "error": None if r.ok else r.text[:300]} if platform == "tiktok": return { "status": "failed", "platform": platform, "error": "TikTok publish placeholder not implemented yet (requires creator upload flow)", } except Exception as exc: return {"status": "failed", "platform": platform, "error": str(exc)} return {"status": "failed", "platform": platform, "error": "Unsupported platform"} def test_connection(platform: str, integration: dict[str, Any] | None = None) -> dict[str, Any]: platform = _normalize_platform(platform) integration = integration or get_integration(platform) if not integration: return {"ok": False, "status": "skipped_not_configured", "error": f"{platform} integration is not configured"} config = integration.get("config") or {} if not _has_credentials(platform, config): return {"ok": False, "status": "skipped_not_configured", "error": f"Missing credentials for {platform}"} # Keep tests lightweight: perform a dry publish without side effects where possible. if platform == "twitter": try: import tweepy # type: ignore client = tweepy.Client( consumer_key=config["api_key"], consumer_secret=config["api_secret"], access_token=config["access_token"], access_token_secret=config["access_secret"], ) _ = client.get_me() return {"ok": True, "status": "ok", "message": "Twitter credentials look valid"} except Exception as exc: return {"ok": False, "status": "failed", "error": str(exc)} return {"ok": True, "status": "ok", "message": f"{platform} configuration is present"} def run_publish_job(job_id: int, text: str, channels: list[str], image_url: str | None, media_ids: list[int]) -> None: started_at = datetime.utcnow() execute( "UPDATE social_publish_jobs SET status=%s, started_at=NOW(), updated_at=NOW() WHERE id=%s", ("running", job_id), ) _log_event( title=f"Social publish job #{job_id} gestart", body=f"Kanalen: {', '.join(channels) if channels else '-'}", status="running", metadata={"job_id": job_id, "channels": channels}, ) chosen_image_url = image_url if not chosen_image_url and media_ids: media_rows = fetch_all( "SELECT id, media_url, url, file_path FROM marketing_media WHERE id = ANY(%s) ORDER BY id", (media_ids,), ) if media_rows: first = media_rows[0] chosen_image_url = first.get("media_url") or first.get("url") results: list[dict[str, Any]] = [] for channel in channels: result = publish_to_channel(channel, text=text, image_url=chosen_image_url) results.append(result) published = sum(1 for item in results if item.get("status") == "published") skipped = sum(1 for item in results if item.get("status") == "skipped_not_configured") failed = len(results) - published - skipped final_status = "completed" if published == 0 and failed > 0: final_status = "failed" elif failed > 0: final_status = "completed_with_errors" execute( """ UPDATE social_publish_jobs SET status=%s, finished_at=NOW(), updated_at=NOW(), result=%s::jsonb WHERE id=%s """, ( final_status, json.dumps( { "published": published, "skipped": skipped, "failed": failed, "channels": channels, "results": results, "started_at": started_at.isoformat(), } ), job_id, ), ) _log_event( title=f"Social publish job #{job_id} afgerond", body=f"Published={published}, skipped={skipped}, failed={failed}", status=final_status, metadata={"job_id": job_id, "results": results}, )