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
}