feat(phase-2): Complete social media API integration

- Add ImageUploadService for public URL generation (Meta APIs)
- Create PublisherManager for unified multi-platform publishing
- Add /api/publish endpoints (single, multiple, thread, test)
- Add compose page in dashboard for creating posts
- Add connection test script (scripts/test_connections.py)
- Update navigation with compose link and logout

New endpoints:
- POST /api/publish/single - Publish to one platform
- POST /api/publish/multiple - Publish to multiple platforms
- POST /api/publish/thread - Publish thread (X/Threads)
- GET /api/publish/test - Test all API connections
- GET /api/publish/platforms - List available platforms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 01:43:22 +00:00
parent cda224f852
commit 3caf2a67fb
9 changed files with 1596 additions and 6 deletions

View File

@@ -83,6 +83,19 @@ async def dashboard_home(request: Request, db: Session = Depends(get_db)):
})
@router.get("/compose", response_class=HTMLResponse)
async def dashboard_compose(request: Request, db: Session = Depends(get_db)):
"""Página para crear nuevas publicaciones."""
user = require_auth(request, db)
if not user:
return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("compose.html", {
"request": request,
"user": user.to_dict()
})
@router.get("/posts", response_class=HTMLResponse)
async def dashboard_posts(request: Request, db: Session = Depends(get_db)):
"""Página de gestión de posts."""

262
app/api/routes/publish.py Normal file
View File

@@ -0,0 +1,262 @@
"""
API endpoints para publicación en redes sociales.
"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.publishers.manager import publisher_manager, Platform, MultiPublishResult
router = APIRouter()
# ============================================================
# Schemas
# ============================================================
class PublishRequest(BaseModel):
"""Solicitud de publicación simple."""
platform: str
content: str
image_url: Optional[str] = None
class MultiPublishRequest(BaseModel):
"""Solicitud de publicación múltiple."""
platforms: List[str]
content: dict # {platform: content} o string único
image_url: Optional[str] = None
class ThreadPublishRequest(BaseModel):
"""Solicitud de publicación de hilo."""
platform: str
posts: List[str]
images: Optional[List[str]] = None
class PublishResponse(BaseModel):
"""Respuesta de publicación."""
success: bool
post_id: Optional[str] = None
url: Optional[str] = None
error: Optional[str] = None
class ConnectionTestResponse(BaseModel):
"""Respuesta de test de conexión."""
platform: str
configured: bool
connected: bool
error: Optional[str] = None
details: dict = {}
# ============================================================
# Endpoints
# ============================================================
@router.post("/single", response_model=PublishResponse)
async def publish_single(request: PublishRequest):
"""
Publicar contenido en una plataforma específica.
- **platform**: x, threads, facebook, instagram
- **content**: Texto del post
- **image_url**: URL o ruta de imagen (opcional)
"""
try:
platform = Platform(request.platform.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {request.platform}. "
f"Opciones: x, threads, facebook, instagram"
)
result = await publisher_manager.publish(
platform=platform,
content=request.content,
image_path=request.image_url
)
return PublishResponse(
success=result.success,
post_id=result.post_id,
url=result.url,
error=result.error_message
)
@router.post("/multiple")
async def publish_multiple(request: MultiPublishRequest):
"""
Publicar contenido en múltiples plataformas.
- **platforms**: Lista de plataformas ["x", "threads", "facebook"]
- **content**: Dict con contenido por plataforma o string único
- **image_url**: URL o ruta de imagen (opcional)
"""
platforms = []
for p in request.platforms:
try:
platforms.append(Platform(p.lower()))
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {p}"
)
result = await publisher_manager.publish_to_multiple(
platforms=platforms,
content=request.content,
image_path=request.image_url
)
return {
"success": result.success,
"successful_platforms": result.successful_platforms,
"failed_platforms": result.failed_platforms,
"results": {
p: {
"success": r.success,
"post_id": r.post_id,
"url": r.url,
"error": r.error_message
}
for p, r in result.results.items()
},
"errors": result.errors
}
@router.post("/thread", response_model=PublishResponse)
async def publish_thread(request: ThreadPublishRequest):
"""
Publicar un hilo de posts en una plataforma.
- **platform**: x o threads
- **posts**: Lista de textos para cada post del hilo
- **images**: Lista de URLs de imágenes (opcional)
"""
try:
platform = Platform(request.platform.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {request.platform}"
)
if platform not in [Platform.X, Platform.THREADS]:
raise HTTPException(
status_code=400,
detail="Los hilos solo están soportados en X y Threads"
)
result = await publisher_manager.publish_thread(
platform=platform,
posts=request.posts,
images=request.images
)
return PublishResponse(
success=result.success,
post_id=result.post_id,
url=result.url,
error=result.error_message
)
@router.get("/test/{platform}", response_model=ConnectionTestResponse)
async def test_connection(platform: str):
"""
Probar conexión con una plataforma específica.
Verifica que las credenciales estén configuradas y funcionando.
"""
try:
plat = Platform(platform.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {platform}"
)
result = await publisher_manager.test_connection(plat)
return ConnectionTestResponse(**result)
@router.get("/test")
async def test_all_connections():
"""
Probar conexión con todas las plataformas.
Devuelve el estado de cada plataforma configurada.
"""
results = await publisher_manager.test_all_connections()
return {
"platforms": results,
"summary": {
"total": len(results),
"configured": sum(1 for r in results.values() if r["configured"]),
"connected": sum(1 for r in results.values() if r["connected"])
}
}
@router.get("/platforms")
async def get_available_platforms():
"""
Obtener lista de plataformas disponibles.
Solo devuelve plataformas con credenciales configuradas.
"""
return {
"available": publisher_manager.get_available_platforms(),
"all": [p.value for p in Platform]
}
class PreviewRequest(BaseModel):
"""Solicitud de previsualización."""
content: str
platforms: List[str]
@router.post("/preview")
async def preview_content(request: PreviewRequest):
"""
Previsualizar cómo se verá el contenido en cada plataforma.
Valida longitud y muestra advertencias.
"""
previews = {}
char_limits = {
"x": 280,
"threads": 500,
"instagram": 2200,
"facebook": 63206
}
for p in request.platforms:
limit = char_limits.get(p.lower(), 500)
length = len(request.content)
previews[p] = {
"content": request.content[:limit],
"length": length,
"limit": limit,
"valid": length <= limit,
"truncated": length > limit,
"remaining": max(0, limit - length)
}
return {
"original_length": len(request.content),
"previews": previews
}

View File

@@ -11,7 +11,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import posts, products, services, calendar, dashboard, interactions, auth
from app.api.routes import posts, products, services, calendar, dashboard, interactions, auth, publish
from app.core.config import settings
from app.core.database import engine
from app.models import Base
@@ -49,6 +49,11 @@ app.add_middleware(
# Montar archivos estáticos
app.mount("/static", StaticFiles(directory="dashboard/static"), name="static")
# Montar directorio de uploads para imágenes públicas
import os
os.makedirs("uploads/images", exist_ok=True)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# Registrar rutas
app.include_router(auth.router, prefix="", tags=["Auth"])
app.include_router(dashboard.router, prefix="", tags=["Dashboard"])
@@ -57,6 +62,7 @@ app.include_router(products.router, prefix="/api/products", tags=["Products"])
app.include_router(services.router, prefix="/api/services", tags=["Services"])
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
app.include_router(interactions.router, prefix="/api/interactions", tags=["Interactions"])
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
@app.get("/api/health")

View File

@@ -7,6 +7,7 @@ from app.publishers.x_publisher import XPublisher
from app.publishers.threads_publisher import ThreadsPublisher
from app.publishers.instagram_publisher import InstagramPublisher
from app.publishers.facebook_publisher import FacebookPublisher
from app.publishers.manager import PublisherManager, Platform, publisher_manager
def get_publisher(platform: str) -> BasePublisher:
@@ -31,5 +32,8 @@ __all__ = [
"ThreadsPublisher",
"InstagramPublisher",
"FacebookPublisher",
"PublisherManager",
"Platform",
"publisher_manager",
"get_publisher"
]

343
app/publishers/manager.py Normal file
View File

@@ -0,0 +1,343 @@
"""
PublisherManager - Gestor unificado de publicaciones.
Coordina todos los publishers y proporciona una interfaz única.
"""
import asyncio
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from enum import Enum
from app.publishers.base import PublishResult
from app.publishers.x_publisher import XPublisher
from app.publishers.threads_publisher import ThreadsPublisher
from app.publishers.facebook_publisher import FacebookPublisher
from app.publishers.instagram_publisher import InstagramPublisher
from app.services.image_upload import image_upload
class Platform(str, Enum):
"""Plataformas soportadas."""
X = "x"
THREADS = "threads"
FACEBOOK = "facebook"
INSTAGRAM = "instagram"
@dataclass
class MultiPublishResult:
"""Resultado de publicación en múltiples plataformas."""
success: bool
results: Dict[str, PublishResult]
errors: List[str]
@property
def successful_platforms(self) -> List[str]:
"""Plataformas donde se publicó exitosamente."""
return [p for p, r in self.results.items() if r.success]
@property
def failed_platforms(self) -> List[str]:
"""Plataformas donde falló la publicación."""
return [p for p, r in self.results.items() if not r.success]
class PublisherManager:
"""
Gestor unificado de publicaciones.
Proporciona una interfaz única para publicar contenido
en todas las plataformas soportadas.
"""
def __init__(self):
self._publishers = {}
self._init_publishers()
def _init_publishers(self):
"""Inicializar publishers disponibles."""
self._publishers = {
Platform.X: XPublisher(),
Platform.THREADS: ThreadsPublisher(),
Platform.FACEBOOK: FacebookPublisher(),
Platform.INSTAGRAM: InstagramPublisher(),
}
def get_publisher(self, platform: Platform):
"""Obtener publisher para una plataforma."""
return self._publishers.get(platform)
def get_available_platforms(self) -> List[str]:
"""Obtener lista de plataformas con credenciales configuradas."""
available = []
for platform, publisher in self._publishers.items():
if self._is_configured(platform):
available.append(platform.value)
return available
def _is_configured(self, platform: Platform) -> bool:
"""Verificar si una plataforma tiene credenciales configuradas."""
publisher = self._publishers.get(platform)
if not publisher:
return False
if platform == Platform.X:
return publisher.client is not None
elif platform == Platform.THREADS:
return bool(publisher.access_token and publisher.user_id)
elif platform == Platform.FACEBOOK:
return bool(publisher.access_token and publisher.page_id)
elif platform == Platform.INSTAGRAM:
return bool(publisher.access_token and publisher.account_id)
return False
async def publish(
self,
platform: Platform,
content: str,
image_path: Optional[str] = None
) -> PublishResult:
"""
Publicar en una plataforma específica.
Args:
platform: Plataforma destino
content: Texto del post
image_path: Ruta local a imagen (opcional)
Returns:
PublishResult con el resultado
"""
publisher = self._publishers.get(platform)
if not publisher:
return PublishResult(
success=False,
error_message=f"Plataforma no soportada: {platform}"
)
# Validar contenido
if not publisher.validate_content(content):
return PublishResult(
success=False,
error_message=f"Contenido excede límite de {publisher.char_limit} caracteres"
)
# Subir imagen si es necesario para Meta APIs
public_image_url = None
if image_path and platform in [Platform.THREADS, Platform.FACEBOOK, Platform.INSTAGRAM]:
try:
public_image_url = await image_upload.upload_from_path(image_path)
except Exception as e:
return PublishResult(
success=False,
error_message=f"Error subiendo imagen: {e}"
)
# Publicar
try:
if platform == Platform.X:
return await publisher.publish(content, image_path)
else:
return await publisher.publish(content, public_image_url or image_path)
except Exception as e:
return PublishResult(
success=False,
error_message=f"Error al publicar: {e}"
)
async def publish_to_multiple(
self,
platforms: List[Platform],
content: Dict[str, str],
image_path: Optional[str] = None,
parallel: bool = True
) -> MultiPublishResult:
"""
Publicar en múltiples plataformas.
Args:
platforms: Lista de plataformas
content: Dict con contenido por plataforma {platform: texto}
o string único para todas
image_path: Ruta a imagen (opcional)
parallel: Si True, publica en paralelo
Returns:
MultiPublishResult con resultados por plataforma
"""
results = {}
errors = []
# Normalizar contenido
if isinstance(content, str):
content = {p.value: content for p in platforms}
# Subir imagen una sola vez si es necesario
public_image_url = None
if image_path:
try:
public_image_url = await image_upload.upload_from_path(image_path)
except Exception as e:
errors.append(f"Error subiendo imagen: {e}")
async def publish_to_platform(platform: Platform):
platform_content = content.get(platform.value, "")
if not platform_content:
return platform, PublishResult(
success=False,
error_message="Sin contenido para esta plataforma"
)
# X usa ruta local, Meta usa URL pública
img = image_path if platform == Platform.X else public_image_url
result = await self.publish(platform, platform_content, img)
return platform, result
if parallel:
# Publicar en paralelo
tasks = [publish_to_platform(p) for p in platforms]
platform_results = await asyncio.gather(*tasks, return_exceptions=True)
for item in platform_results:
if isinstance(item, Exception):
errors.append(str(item))
else:
platform, result = item
results[platform.value] = result
else:
# Publicar secuencialmente
for platform in platforms:
try:
_, result = await publish_to_platform(platform)
results[platform.value] = result
except Exception as e:
errors.append(f"{platform.value}: {e}")
results[platform.value] = PublishResult(
success=False,
error_message=str(e)
)
# Determinar éxito global
success = any(r.success for r in results.values())
return MultiPublishResult(
success=success,
results=results,
errors=errors
)
async def publish_thread(
self,
platform: Platform,
posts: List[str],
images: Optional[List[str]] = None
) -> PublishResult:
"""Publicar un hilo en una plataforma."""
publisher = self._publishers.get(platform)
if not publisher:
return PublishResult(
success=False,
error_message=f"Plataforma no soportada: {platform}"
)
return await publisher.publish_thread(posts, images)
async def test_connection(self, platform: Platform) -> Dict[str, Any]:
"""
Probar conexión con una plataforma.
Returns:
Dict con status y detalles
"""
result = {
"platform": platform.value,
"configured": self._is_configured(platform),
"connected": False,
"error": None,
"details": {}
}
if not result["configured"]:
result["error"] = "Credenciales no configuradas"
return result
publisher = self._publishers.get(platform)
try:
if platform == Platform.X:
me = publisher.client.get_me()
if me.data:
result["connected"] = True
result["details"] = {
"username": me.data.username,
"name": me.data.name,
"id": str(me.data.id)
}
elif platform == Platform.THREADS:
import httpx
async with httpx.AsyncClient() as client:
url = f"{publisher.base_url}/{publisher.user_id}"
params = {
"fields": "id,username",
"access_token": publisher.access_token
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
result["connected"] = True
result["details"] = data
elif platform == Platform.FACEBOOK:
import httpx
async with httpx.AsyncClient() as client:
url = f"{publisher.base_url}/{publisher.page_id}"
params = {
"fields": "id,name,followers_count",
"access_token": publisher.access_token
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
result["connected"] = True
result["details"] = data
elif platform == Platform.INSTAGRAM:
import httpx
async with httpx.AsyncClient() as client:
url = f"{publisher.base_url}/{publisher.account_id}"
params = {
"fields": "id,username,followers_count,media_count",
"access_token": publisher.access_token
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
result["connected"] = True
result["details"] = data
except Exception as e:
result["error"] = str(e)
return result
async def test_all_connections(self) -> Dict[str, Dict[str, Any]]:
"""Probar conexión con todas las plataformas."""
results = {}
for platform in Platform:
results[platform.value] = await self.test_connection(platform)
return results
# Instancia global
publisher_manager = PublisherManager()

View File

@@ -0,0 +1,166 @@
"""
Servicio de subida y gestión de imágenes.
Sube imágenes locales y devuelve URLs públicas para las APIs de Meta.
"""
import os
import uuid
import shutil
from pathlib import Path
from typing import Optional
from datetime import datetime
from app.core.config import settings
class ImageUploadService:
"""Servicio para subir imágenes y obtener URLs públicas."""
def __init__(self):
self.upload_dir = Path("uploads/images")
self.upload_dir.mkdir(parents=True, exist_ok=True)
self._base_url: Optional[str] = None
@property
def base_url(self) -> str:
"""URL base para servir imágenes."""
if self._base_url:
return self._base_url
# En producción, usar dominio público
if settings.APP_ENV == "production":
return f"{settings.BUSINESS_WEBSITE}/uploads/images"
# En desarrollo, usar localhost
return "http://localhost:8000/uploads/images"
def set_base_url(self, url: str):
"""Establecer URL base manualmente."""
self._base_url = url.rstrip("/")
def _generate_filename(self, original_name: str) -> str:
"""Generar nombre único para archivo."""
ext = Path(original_name).suffix.lower() or ".png"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = uuid.uuid4().hex[:8]
return f"{timestamp}_{unique_id}{ext}"
async def upload_from_path(self, file_path: str) -> str:
"""
Subir imagen desde ruta local.
Args:
file_path: Ruta al archivo de imagen local
Returns:
URL pública de la imagen
"""
source = Path(file_path)
if not source.exists():
raise FileNotFoundError(f"Archivo no encontrado: {file_path}")
# Generar nombre único
new_name = self._generate_filename(source.name)
dest = self.upload_dir / new_name
# Copiar archivo
shutil.copy2(source, dest)
return f"{self.base_url}/{new_name}"
async def upload_from_bytes(
self,
data: bytes,
filename: str = "image.png"
) -> str:
"""
Subir imagen desde bytes.
Args:
data: Contenido del archivo en bytes
filename: Nombre original para extensión
Returns:
URL pública de la imagen
"""
new_name = self._generate_filename(filename)
dest = self.upload_dir / new_name
dest.write_bytes(data)
return f"{self.base_url}/{new_name}"
async def upload_generated_image(self, image_path: str) -> str:
"""
Subir imagen generada por ImageGenerator.
Alias de upload_from_path para claridad.
"""
return await self.upload_from_path(image_path)
def get_local_path(self, url: str) -> Optional[Path]:
"""Obtener ruta local desde URL."""
if self.base_url not in url:
return None
filename = url.split("/")[-1]
path = self.upload_dir / filename
return path if path.exists() else None
def delete(self, url: str) -> bool:
"""Eliminar imagen por URL."""
path = self.get_local_path(url)
if path and path.exists():
path.unlink()
return True
return False
def cleanup_old(self, days: int = 30) -> int:
"""
Limpiar imágenes antiguas.
Args:
days: Eliminar imágenes más antiguas que estos días
Returns:
Cantidad de archivos eliminados
"""
import time
cutoff = time.time() - (days * 86400)
deleted = 0
for file in self.upload_dir.iterdir():
if file.is_file() and file.stat().st_mtime < cutoff:
file.unlink()
deleted += 1
return deleted
def list_images(self, limit: int = 100) -> list:
"""Listar imágenes subidas."""
images = []
for file in sorted(
self.upload_dir.iterdir(),
key=lambda f: f.stat().st_mtime,
reverse=True
)[:limit]:
if file.is_file():
images.append({
"filename": file.name,
"url": f"{self.base_url}/{file.name}",
"size": file.stat().st_size,
"modified": datetime.fromtimestamp(
file.stat().st_mtime
).isoformat()
})
return images
# Instancia global
image_upload = ImageUploadService()