Files
foodlinkk-command-center/cockpit/app/services/social_publish.py
T
Aissa 5d60d33db1 Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
2026-06-09 00:41:27 +00:00

315 lines
12 KiB
Python

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},
)