343 lines
12 KiB
Python
343 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],
|
|
project_id: int | None = None,
|
|
) -> 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, "project_id": project_id},
|
|
)
|
|
|
|
try:
|
|
from app.services import projects as project_svc
|
|
|
|
project_svc.register_agent_output(
|
|
asset_type="social_publish",
|
|
title=f"Social publish #{job_id} · {final_status}",
|
|
ref_id=str(job_id),
|
|
payload={
|
|
"job_id": job_id,
|
|
"status": final_status,
|
|
"channels": channels,
|
|
"published": published,
|
|
"text_preview": (text or "")[:200],
|
|
},
|
|
project_id=project_id,
|
|
source_agent="marketing",
|
|
created_by="marketing_automation",
|
|
)
|
|
except Exception:
|
|
pass
|