Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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},
|
||||
)
|
||||
Reference in New Issue
Block a user