commit 049d2133f9d0026858747fbbcd00d90b64cebd6b Author: Consultoría AS Date: Wed Jan 28 01:11:44 2026 +0000 Implementación inicial del sistema de automatización de redes sociales - Estructura completa del proyecto con FastAPI - Modelos de base de datos (productos, servicios, posts, calendario, interacciones) - Publishers para X, Threads, Instagram, Facebook - Generador de contenido con DeepSeek API - Worker de Celery con tareas programadas - Dashboard básico con templates HTML - Docker Compose para despliegue - Documentación completa Co-Authored-By: Claude Opus 4.5 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..448c511 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:consultoria-as.com)", + "WebFetch(domain:x.com)", + "Bash(git init:*)", + "Bash(git add:*)", + "Bash(git commit -m \"$\\(cat <<''EOF''\nImplementación inicial del sistema de automatización de redes sociales\n\n- Estructura completa del proyecto con FastAPI\n- Modelos de base de datos \\(productos, servicios, posts, calendario, interacciones\\)\n- Publishers para X, Threads, Instagram, Facebook\n- Generador de contenido con DeepSeek API\n- Worker de Celery con tareas programadas\n- Dashboard básico con templates HTML\n- Docker Compose para despliegue\n- Documentación completa\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git config:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..82e07f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,74 @@ +# =========================================== +# CONFIGURACIÓN DEL SISTEMA +# =========================================== + +# Aplicación +APP_NAME=social-media-automation +APP_ENV=development +DEBUG=true +SECRET_KEY=your-secret-key-here-change-in-production + +# =========================================== +# BASE DE DATOS +# =========================================== +DATABASE_URL=postgresql://user:password@localhost:5432/social_automation + +# =========================================== +# REDIS +# =========================================== +REDIS_URL=redis://localhost:6379/0 + +# =========================================== +# DEEPSEEK API (Generación de contenido) +# =========================================== +DEEPSEEK_API_KEY=your-deepseek-api-key +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 + +# =========================================== +# X (TWITTER) API +# =========================================== +X_API_KEY=your-x-api-key +X_API_SECRET=your-x-api-secret +X_ACCESS_TOKEN=your-x-access-token +X_ACCESS_TOKEN_SECRET=your-x-access-token-secret +X_BEARER_TOKEN=your-x-bearer-token + +# =========================================== +# META API (Facebook, Instagram, Threads) +# =========================================== +META_APP_ID=your-meta-app-id +META_APP_SECRET=your-meta-app-secret +META_ACCESS_TOKEN=your-meta-access-token + +# Facebook +FACEBOOK_PAGE_ID=your-facebook-page-id + +# Instagram +INSTAGRAM_ACCOUNT_ID=your-instagram-account-id + +# Threads +THREADS_USER_ID=your-threads-user-id + +# =========================================== +# CONFIGURACIÓN DE CONTENIDO +# =========================================== + +# Información del negocio +BUSINESS_NAME=Consultoría AS +BUSINESS_LOCATION=Tijuana, México +BUSINESS_WEBSITE=https://consultoria-as.com + +# Tono de comunicación +CONTENT_TONE=profesional pero cercano, educativo, orientado a soluciones + +# =========================================== +# NOTIFICACIONES (Opcional) +# =========================================== +TELEGRAM_BOT_TOKEN=your-telegram-bot-token +TELEGRAM_CHAT_ID=your-telegram-chat-id + +# Email +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your-email@example.com +SMTP_PASSWORD=your-email-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..783f724 --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite3 + +# Uploads +uploads/ +!uploads/.gitkeep + +# Generated images +templates/*.png + +# Celery +celerybeat-schedule +celerybeat.pid + +# Coverage +htmlcov/ +.coverage +.coverage.* + +# pytest +.pytest_cache/ + +# mypy +.mypy_cache/ + +# Docker volumes +postgres_data/ +redis_data/ + +# SSL certificates +nginx/ssl/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# Secrets (nunca subir) +*secret* +*credential* +*.pem +*.key diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4549ea4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +FROM python:3.11-slim + +# Establecer directorio de trabajo +WORKDIR /app + +# Variables de entorno +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + chromium \ + chromium-driver \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libatspi2.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + xdg-utils \ + && rm -rf /var/lib/apt/lists/* + +# Copiar requirements e instalar dependencias Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar código de la aplicación +COPY . . + +# Crear directorio para uploads +RUN mkdir -p /app/uploads + +# Exponer puerto +EXPOSE 8000 + +# Comando por defecto +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..877e78d --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Social Media Automation - Consultoría AS + +Sistema automatizado para la creación y publicación de contenido en redes sociales (X, Threads, Instagram, Facebook). + +## Características + +- **Generación de contenido con IA** (DeepSeek API) +- **Publicación automática** en múltiples plataformas +- **Calendario de contenido** configurable +- **Dashboard de gestión** para aprobar y revisar posts +- **Gestión de interacciones** (comentarios, menciones, DMs) +- **Plantillas de imágenes** personalizadas + +## Stack Tecnológico + +- **Backend**: Python + FastAPI +- **Base de datos**: PostgreSQL +- **Cola de tareas**: Celery + Redis +- **IA**: DeepSeek API +- **Contenedores**: Docker Compose + +## Instalación + +### Requisitos Previos + +- Docker y Docker Compose +- Cuentas de desarrollador en: + - [X Developer](https://developer.twitter.com) + - [Meta Developer](https://developers.facebook.com) + - [DeepSeek](https://platform.deepseek.com) + +### Pasos + +1. **Clonar el repositorio** +```bash +git clone https://git.consultoria-as.com/consultoria-as/social-media-automation.git +cd social-media-automation +``` + +2. **Configurar variables de entorno** +```bash +cp .env.example .env +# Editar .env con tus credenciales +``` + +3. **Iniciar servicios** +```bash +docker-compose up -d +``` + +4. **Poblar base de datos** +```bash +docker-compose exec app python scripts/seed_database.py +``` + +5. **Acceder al dashboard** +``` +http://localhost:8000 +``` + +## Estructura del Proyecto + +``` +├── app/ # Aplicación principal +│ ├── api/ # Endpoints de la API +│ ├── core/ # Configuración +│ ├── models/ # Modelos SQLAlchemy +│ ├── publishers/ # Publishers por plataforma +│ ├── services/ # Lógica de negocio +│ └── prompts/ # Prompts para IA +├── worker/ # Celery workers +│ └── tasks/ # Tareas programadas +├── dashboard/ # Frontend del dashboard +├── templates/ # Plantillas de imágenes +├── data/ # Datos iniciales +├── scripts/ # Scripts de utilidad +└── docker-compose.yml # Configuración Docker +``` + +## Uso + +### Dashboard + +- **Home**: Resumen y estadísticas +- **Posts**: Gestionar publicaciones +- **Calendario**: Configurar horarios +- **Interacciones**: Responder comentarios +- **Productos**: Gestionar catálogo + +### API + +Documentación disponible en: +- Swagger: `http://localhost:8000/api/docs` +- ReDoc: `http://localhost:8000/api/redoc` + +### Endpoints Principales + +``` +GET /api/posts # Listar posts +POST /api/posts # Crear post +GET /api/posts/pending # Posts pendientes +POST /api/posts/{id}/approve # Aprobar post + +GET /api/products # Listar productos +POST /api/products # Crear producto + +GET /api/calendar # Ver calendario +POST /api/calendar # Crear entrada + +GET /api/interactions # Ver interacciones +POST /api/interactions/{id}/respond # Responder +``` + +## Configuración del Calendario + +El calendario determina qué tipo de contenido se publica y cuándo: + +| Día | Hora | Tipo | Plataformas | +|-----|------|------|-------------| +| L-V | 09:00 | Tip Tech | X, Threads, IG | +| L-V | 15:00 | Dato Curioso | X, Threads | +| L,M,V | 12:00 | Producto | FB, IG | +| M,J | 11:00 | Servicio | X, FB, IG | + +## Desarrollo + +### Ejecutar sin Docker + +```bash +# Instalar dependencias +pip install -r requirements.txt + +# Variables de entorno +export DATABASE_URL=postgresql://... +export REDIS_URL=redis://... + +# Iniciar API +uvicorn app.main:app --reload + +# Iniciar Worker +celery -A worker.celery_app worker --loglevel=info + +# Iniciar Beat +celery -A worker.celery_app beat --loglevel=info +``` + +### Tests + +```bash +pytest tests/ -v +``` + +## Licencia + +Propiedad de Consultoría AS - Todos los derechos reservados. + +## Contacto + +- Web: https://consultoria-as.com +- Email: contacto@consultoria-as.com diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3e4435a --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Social Media Automation - Consultoría AS diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..0154438 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API Routes diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..b8ca653 --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1 @@ +# API Route modules diff --git a/app/api/routes/calendar.py b/app/api/routes/calendar.py new file mode 100644 index 0000000..b4ae038 --- /dev/null +++ b/app/api/routes/calendar.py @@ -0,0 +1,201 @@ +""" +API Routes para gestión del Calendario de Contenido. +""" + +from datetime import datetime, time +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.database import get_db +from app.models.content_calendar import ContentCalendar + +router = APIRouter() + + +# =========================================== +# SCHEMAS +# =========================================== + +class CalendarEntryCreate(BaseModel): + day_of_week: int # 0=Lunes, 6=Domingo + time: str # Formato "HH:MM" + content_type: str + platforms: List[str] + requires_approval: bool = False + category_filter: Optional[str] = None + priority: int = 0 + description: Optional[str] = None + + class Config: + from_attributes = True + + +class CalendarEntryUpdate(BaseModel): + day_of_week: Optional[int] = None + time: Optional[str] = None + content_type: Optional[str] = None + platforms: Optional[List[str]] = None + is_active: Optional[bool] = None + requires_approval: Optional[bool] = None + category_filter: Optional[str] = None + priority: Optional[int] = None + description: Optional[str] = None + + class Config: + from_attributes = True + + +# =========================================== +# ENDPOINTS +# =========================================== + +@router.get("/") +async def list_calendar_entries( + day_of_week: Optional[int] = Query(None), + content_type: Optional[str] = Query(None), + platform: Optional[str] = Query(None), + is_active: Optional[bool] = Query(True), + db: Session = Depends(get_db) +): + """Listar entradas del calendario.""" + query = db.query(ContentCalendar) + + if day_of_week is not None: + query = query.filter(ContentCalendar.day_of_week == day_of_week) + if content_type: + query = query.filter(ContentCalendar.content_type == content_type) + if is_active is not None: + query = query.filter(ContentCalendar.is_active == is_active) + if platform: + query = query.filter(ContentCalendar.platforms.contains([platform])) + + entries = query.order_by( + ContentCalendar.day_of_week, + ContentCalendar.time + ).all() + + return [e.to_dict() for e in entries] + + +@router.get("/week") +async def get_week_schedule(db: Session = Depends(get_db)): + """Obtener el calendario semanal completo.""" + entries = db.query(ContentCalendar).filter( + ContentCalendar.is_active == True + ).order_by( + ContentCalendar.day_of_week, + ContentCalendar.time + ).all() + + # Organizar por día + week = {i: [] for i in range(7)} + for entry in entries: + week[entry.day_of_week].append(entry.to_dict()) + + days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"] + return { + days[i]: week[i] for i in range(7) + } + + +@router.get("/today") +async def get_today_schedule(db: Session = Depends(get_db)): + """Obtener el calendario de hoy.""" + today = datetime.now().weekday() + + entries = db.query(ContentCalendar).filter( + ContentCalendar.day_of_week == today, + ContentCalendar.is_active == True + ).order_by(ContentCalendar.time).all() + + return [e.to_dict() for e in entries] + + +@router.get("/{entry_id}") +async def get_calendar_entry(entry_id: int, db: Session = Depends(get_db)): + """Obtener una entrada del calendario por ID.""" + entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first() + if not entry: + raise HTTPException(status_code=404, detail="Entrada no encontrada") + return entry.to_dict() + + +@router.post("/") +async def create_calendar_entry(entry_data: CalendarEntryCreate, db: Session = Depends(get_db)): + """Crear una nueva entrada en el calendario.""" + # Parsear hora + hour, minute = map(int, entry_data.time.split(":")) + entry_time = time(hour=hour, minute=minute) + + entry = ContentCalendar( + day_of_week=entry_data.day_of_week, + time=entry_time, + content_type=entry_data.content_type, + platforms=entry_data.platforms, + requires_approval=entry_data.requires_approval, + category_filter=entry_data.category_filter, + priority=entry_data.priority, + description=entry_data.description + ) + + db.add(entry) + db.commit() + db.refresh(entry) + return entry.to_dict() + + +@router.put("/{entry_id}") +async def update_calendar_entry( + entry_id: int, + entry_data: CalendarEntryUpdate, + db: Session = Depends(get_db) +): + """Actualizar una entrada del calendario.""" + entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first() + if not entry: + raise HTTPException(status_code=404, detail="Entrada no encontrada") + + update_data = entry_data.dict(exclude_unset=True) + + # Parsear hora si se proporciona + if "time" in update_data and update_data["time"]: + hour, minute = map(int, update_data["time"].split(":")) + update_data["time"] = time(hour=hour, minute=minute) + + for field, value in update_data.items(): + setattr(entry, field, value) + + entry.updated_at = datetime.utcnow() + db.commit() + db.refresh(entry) + return entry.to_dict() + + +@router.delete("/{entry_id}") +async def delete_calendar_entry(entry_id: int, db: Session = Depends(get_db)): + """Eliminar una entrada del calendario.""" + entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first() + if not entry: + raise HTTPException(status_code=404, detail="Entrada no encontrada") + + db.delete(entry) + db.commit() + return {"message": "Entrada eliminada", "entry_id": entry_id} + + +@router.post("/{entry_id}/toggle") +async def toggle_calendar_entry(entry_id: int, db: Session = Depends(get_db)): + """Activar/desactivar una entrada del calendario.""" + entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first() + if not entry: + raise HTTPException(status_code=404, detail="Entrada no encontrada") + + entry.is_active = not entry.is_active + db.commit() + + return { + "message": f"Entrada {'activada' if entry.is_active else 'desactivada'}", + "is_active": entry.is_active + } diff --git a/app/api/routes/dashboard.py b/app/api/routes/dashboard.py new file mode 100644 index 0000000..94b3c77 --- /dev/null +++ b/app/api/routes/dashboard.py @@ -0,0 +1,118 @@ +""" +API Routes para el Dashboard. +""" + +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.core.database import get_db +from app.models.post import Post +from app.models.interaction import Interaction + +router = APIRouter() + +templates = Jinja2Templates(directory="dashboard/templates") + + +@router.get("/", response_class=HTMLResponse) +async def dashboard_home(request: Request, db: Session = Depends(get_db)): + """Página principal del dashboard.""" + # Estadísticas + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - timedelta(days=now.weekday()) + + stats = { + "posts_today": db.query(Post).filter( + Post.published_at >= today_start + ).count(), + "posts_week": db.query(Post).filter( + Post.published_at >= week_start + ).count(), + "pending_approval": db.query(Post).filter( + Post.status == "pending_approval" + ).count(), + "scheduled": db.query(Post).filter( + Post.status == "scheduled" + ).count(), + "interactions_pending": db.query(Interaction).filter( + Interaction.responded == False, + Interaction.is_archived == False + ).count() + } + + # Posts pendientes + pending_posts = db.query(Post).filter( + Post.status == "pending_approval" + ).order_by(Post.scheduled_at.asc()).limit(5).all() + + # Próximas publicaciones + scheduled_posts = db.query(Post).filter( + Post.status == "scheduled", + Post.scheduled_at >= now + ).order_by(Post.scheduled_at.asc()).limit(5).all() + + # Interacciones recientes + recent_interactions = db.query(Interaction).filter( + Interaction.responded == False + ).order_by(Interaction.interaction_at.desc()).limit(5).all() + + return templates.TemplateResponse("index.html", { + "request": request, + "stats": stats, + "pending_posts": [p.to_dict() for p in pending_posts], + "scheduled_posts": [p.to_dict() for p in scheduled_posts], + "recent_interactions": [i.to_dict() for i in recent_interactions] + }) + + +@router.get("/posts", response_class=HTMLResponse) +async def dashboard_posts(request: Request, db: Session = Depends(get_db)): + """Página de gestión de posts.""" + posts = db.query(Post).order_by(Post.created_at.desc()).limit(50).all() + + return templates.TemplateResponse("posts.html", { + "request": request, + "posts": [p.to_dict() for p in posts] + }) + + +@router.get("/calendar", response_class=HTMLResponse) +async def dashboard_calendar(request: Request): + """Página de calendario.""" + return templates.TemplateResponse("calendar.html", { + "request": request + }) + + +@router.get("/interactions", response_class=HTMLResponse) +async def dashboard_interactions(request: Request, db: Session = Depends(get_db)): + """Página de interacciones.""" + interactions = db.query(Interaction).filter( + Interaction.is_archived == False + ).order_by(Interaction.interaction_at.desc()).limit(50).all() + + return templates.TemplateResponse("interactions.html", { + "request": request, + "interactions": [i.to_dict() for i in interactions] + }) + + +@router.get("/products", response_class=HTMLResponse) +async def dashboard_products(request: Request): + """Página de productos.""" + return templates.TemplateResponse("products.html", { + "request": request + }) + + +@router.get("/services", response_class=HTMLResponse) +async def dashboard_services(request: Request): + """Página de servicios.""" + return templates.TemplateResponse("services.html", { + "request": request + }) diff --git a/app/api/routes/interactions.py b/app/api/routes/interactions.py new file mode 100644 index 0000000..96c56c7 --- /dev/null +++ b/app/api/routes/interactions.py @@ -0,0 +1,226 @@ +""" +API Routes para gestión de Interacciones. +""" + +from datetime import datetime +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.database import get_db +from app.models.interaction import Interaction + +router = APIRouter() + + +# =========================================== +# SCHEMAS +# =========================================== + +class InteractionResponse(BaseModel): + content: str + + class Config: + from_attributes = True + + +# =========================================== +# ENDPOINTS +# =========================================== + +@router.get("/") +async def list_interactions( + platform: Optional[str] = Query(None), + interaction_type: Optional[str] = Query(None), + responded: Optional[bool] = Query(None), + is_lead: Optional[bool] = Query(None), + is_read: Optional[bool] = Query(None), + limit: int = Query(50, le=100), + offset: int = Query(0), + db: Session = Depends(get_db) +): + """Listar interacciones con filtros.""" + query = db.query(Interaction) + + if platform: + query = query.filter(Interaction.platform == platform) + if interaction_type: + query = query.filter(Interaction.interaction_type == interaction_type) + if responded is not None: + query = query.filter(Interaction.responded == responded) + if is_lead is not None: + query = query.filter(Interaction.is_lead == is_lead) + if is_read is not None: + query = query.filter(Interaction.is_read == is_read) + + interactions = query.order_by( + Interaction.interaction_at.desc() + ).offset(offset).limit(limit).all() + + return [i.to_dict() for i in interactions] + + +@router.get("/pending") +async def list_pending_interactions(db: Session = Depends(get_db)): + """Listar interacciones sin responder.""" + interactions = db.query(Interaction).filter( + Interaction.responded == False, + Interaction.is_archived == False + ).order_by( + Interaction.priority.desc(), + Interaction.interaction_at.desc() + ).all() + + return [i.to_dict() for i in interactions] + + +@router.get("/leads") +async def list_leads(db: Session = Depends(get_db)): + """Listar interacciones marcadas como leads.""" + interactions = db.query(Interaction).filter( + Interaction.is_lead == True + ).order_by(Interaction.interaction_at.desc()).all() + + return [i.to_dict() for i in interactions] + + +@router.get("/stats") +async def get_interaction_stats(db: Session = Depends(get_db)): + """Obtener estadísticas de interacciones.""" + total = db.query(Interaction).count() + pending = db.query(Interaction).filter( + Interaction.responded == False, + Interaction.is_archived == False + ).count() + leads = db.query(Interaction).filter(Interaction.is_lead == True).count() + + # Por plataforma + by_platform = {} + for platform in ["x", "threads", "instagram", "facebook"]: + by_platform[platform] = db.query(Interaction).filter( + Interaction.platform == platform + ).count() + + return { + "total": total, + "pending": pending, + "leads": leads, + "by_platform": by_platform + } + + +@router.get("/{interaction_id}") +async def get_interaction(interaction_id: int, db: Session = Depends(get_db)): + """Obtener una interacción por ID.""" + interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + raise HTTPException(status_code=404, detail="Interacción no encontrada") + return interaction.to_dict() + + +@router.post("/{interaction_id}/respond") +async def respond_to_interaction( + interaction_id: int, + response_data: InteractionResponse, + db: Session = Depends(get_db) +): + """Responder a una interacción.""" + interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + raise HTTPException(status_code=404, detail="Interacción no encontrada") + + # TODO: Enviar respuesta a la plataforma correspondiente + # from app.publishers import get_publisher + # publisher = get_publisher(interaction.platform) + # result = await publisher.reply(interaction.external_id, response_data.content) + + interaction.responded = True + interaction.response_content = response_data.content + interaction.responded_at = datetime.utcnow() + db.commit() + + return { + "message": "Respuesta guardada", + "interaction_id": interaction_id, + "note": "La publicación a la plataforma está en desarrollo" + } + + +@router.post("/{interaction_id}/suggest-response") +async def suggest_response(interaction_id: int, db: Session = Depends(get_db)): + """Generar sugerencia de respuesta con IA.""" + interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + raise HTTPException(status_code=404, detail="Interacción no encontrada") + + # TODO: Implementar generación con DeepSeek + # from app.services.content_generator import generate_response_suggestion + # suggestions = await generate_response_suggestion(interaction) + + # Respuestas placeholder + suggestions = [ + f"¡Gracias por tu comentario! Nos da gusto que te interese nuestro contenido.", + f"¡Hola! Gracias por escribirnos. ¿En qué podemos ayudarte?", + f"¡Excelente pregunta! Te respondemos por DM para darte más detalles." + ] + + return { + "suggestions": suggestions, + "note": "La generación con IA está en desarrollo" + } + + +@router.post("/{interaction_id}/mark-as-lead") +async def mark_as_lead(interaction_id: int, db: Session = Depends(get_db)): + """Marcar interacción como lead potencial.""" + interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + raise HTTPException(status_code=404, detail="Interacción no encontrada") + + interaction.is_lead = not interaction.is_lead + db.commit() + + return { + "message": f"{'Marcado' if interaction.is_lead else 'Desmarcado'} como lead", + "is_lead": interaction.is_lead + } + + +@router.post("/{interaction_id}/mark-as-read") +async def mark_as_read(interaction_id: int, db: Session = Depends(get_db)): + """Marcar interacción como leída.""" + interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + raise HTTPException(status_code=404, detail="Interacción no encontrada") + + interaction.is_read = True + db.commit() + + return {"message": "Marcado como leído", "interaction_id": interaction_id} + + +@router.post("/{interaction_id}/archive") +async def archive_interaction(interaction_id: int, db: Session = Depends(get_db)): + """Archivar una interacción.""" + interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + raise HTTPException(status_code=404, detail="Interacción no encontrada") + + interaction.is_archived = True + db.commit() + + return {"message": "Interacción archivada", "interaction_id": interaction_id} + + +@router.delete("/{interaction_id}") +async def delete_interaction(interaction_id: int, db: Session = Depends(get_db)): + """Eliminar una interacción.""" + interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + raise HTTPException(status_code=404, detail="Interacción no encontrada") + + db.delete(interaction) + db.commit() + + return {"message": "Interacción eliminada", "interaction_id": interaction_id} diff --git a/app/api/routes/posts.py b/app/api/routes/posts.py new file mode 100644 index 0000000..c9b50fd --- /dev/null +++ b/app/api/routes/posts.py @@ -0,0 +1,216 @@ +""" +API Routes para gestión de Posts. +""" + +from datetime import datetime +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.database import get_db +from app.models.post import Post + +router = APIRouter() + + +# =========================================== +# SCHEMAS +# =========================================== + +class PostCreate(BaseModel): + content: str + content_type: str + platforms: List[str] + scheduled_at: Optional[datetime] = None + image_url: Optional[str] = None + approval_required: bool = False + hashtags: Optional[List[str]] = None + + class Config: + from_attributes = True + + +class PostUpdate(BaseModel): + content: Optional[str] = None + content_x: Optional[str] = None + content_threads: Optional[str] = None + content_instagram: Optional[str] = None + content_facebook: Optional[str] = None + platforms: Optional[List[str]] = None + scheduled_at: Optional[datetime] = None + status: Optional[str] = None + image_url: Optional[str] = None + hashtags: Optional[List[str]] = None + + class Config: + from_attributes = True + + +class PostResponse(BaseModel): + id: int + content: str + content_type: str + platforms: List[str] + status: str + scheduled_at: Optional[datetime] + published_at: Optional[datetime] + image_url: Optional[str] + approval_required: bool + created_at: datetime + + class Config: + from_attributes = True + + +# =========================================== +# ENDPOINTS +# =========================================== + +@router.get("/", response_model=List[PostResponse]) +async def list_posts( + status: Optional[str] = Query(None, description="Filtrar por estado"), + content_type: Optional[str] = Query(None, description="Filtrar por tipo"), + platform: Optional[str] = Query(None, description="Filtrar por plataforma"), + limit: int = Query(50, le=100), + offset: int = Query(0), + db: Session = Depends(get_db) +): + """Listar posts con filtros opcionales.""" + query = db.query(Post) + + if status: + query = query.filter(Post.status == status) + if content_type: + query = query.filter(Post.content_type == content_type) + if platform: + query = query.filter(Post.platforms.contains([platform])) + + posts = query.order_by(Post.created_at.desc()).offset(offset).limit(limit).all() + return posts + + +@router.get("/pending") +async def list_pending_posts(db: Session = Depends(get_db)): + """Listar posts pendientes de aprobación.""" + posts = db.query(Post).filter( + Post.status == "pending_approval" + ).order_by(Post.scheduled_at.asc()).all() + + return [post.to_dict() for post in posts] + + +@router.get("/scheduled") +async def list_scheduled_posts(db: Session = Depends(get_db)): + """Listar posts programados.""" + posts = db.query(Post).filter( + Post.status == "scheduled", + Post.scheduled_at >= datetime.utcnow() + ).order_by(Post.scheduled_at.asc()).all() + + return [post.to_dict() for post in posts] + + +@router.get("/{post_id}") +async def get_post(post_id: int, db: Session = Depends(get_db)): + """Obtener un post por ID.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="Post no encontrado") + return post.to_dict() + + +@router.post("/", response_model=PostResponse) +async def create_post(post_data: PostCreate, db: Session = Depends(get_db)): + """Crear un nuevo post.""" + post = Post( + content=post_data.content, + content_type=post_data.content_type, + platforms=post_data.platforms, + scheduled_at=post_data.scheduled_at, + image_url=post_data.image_url, + approval_required=post_data.approval_required, + hashtags=post_data.hashtags, + status="pending_approval" if post_data.approval_required else "scheduled" + ) + + db.add(post) + db.commit() + db.refresh(post) + + return post + + +@router.put("/{post_id}") +async def update_post(post_id: int, post_data: PostUpdate, db: Session = Depends(get_db)): + """Actualizar un post.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="Post no encontrado") + + update_data = post_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(post, field, value) + + post.updated_at = datetime.utcnow() + db.commit() + db.refresh(post) + + return post.to_dict() + + +@router.post("/{post_id}/approve") +async def approve_post(post_id: int, db: Session = Depends(get_db)): + """Aprobar un post pendiente.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="Post no encontrado") + + if post.status != "pending_approval": + raise HTTPException(status_code=400, detail="El post no está pendiente de aprobación") + + post.status = "scheduled" + post.approved_at = datetime.utcnow() + db.commit() + + return {"message": "Post aprobado", "post_id": post_id} + + +@router.post("/{post_id}/reject") +async def reject_post(post_id: int, db: Session = Depends(get_db)): + """Rechazar un post pendiente.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="Post no encontrado") + + post.status = "cancelled" + db.commit() + + return {"message": "Post rechazado", "post_id": post_id} + + +@router.post("/{post_id}/regenerate") +async def regenerate_post(post_id: int, db: Session = Depends(get_db)): + """Regenerar contenido de un post con IA.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="Post no encontrado") + + # TODO: Implementar regeneración con DeepSeek + # from app.services.content_generator import regenerate_content + # new_content = await regenerate_content(post) + + return {"message": "Regeneración en desarrollo", "post_id": post_id} + + +@router.delete("/{post_id}") +async def delete_post(post_id: int, db: Session = Depends(get_db)): + """Eliminar un post.""" + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="Post no encontrado") + + db.delete(post) + db.commit() + + return {"message": "Post eliminado", "post_id": post_id} diff --git a/app/api/routes/products.py b/app/api/routes/products.py new file mode 100644 index 0000000..8487e4d --- /dev/null +++ b/app/api/routes/products.py @@ -0,0 +1,180 @@ +""" +API Routes para gestión de Productos. +""" + +from datetime import datetime +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.database import get_db +from app.models.product import Product + +router = APIRouter() + + +# =========================================== +# SCHEMAS +# =========================================== + +class ProductCreate(BaseModel): + name: str + description: Optional[str] = None + short_description: Optional[str] = None + category: str + subcategory: Optional[str] = None + brand: Optional[str] = None + model: Optional[str] = None + price: float + price_usd: Optional[float] = None + stock: int = 0 + specs: Optional[dict] = None + images: Optional[List[str]] = None + main_image: Optional[str] = None + tags: Optional[List[str]] = None + highlights: Optional[List[str]] = None + is_featured: bool = False + + class Config: + from_attributes = True + + +class ProductUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + short_description: Optional[str] = None + category: Optional[str] = None + subcategory: Optional[str] = None + brand: Optional[str] = None + model: Optional[str] = None + price: Optional[float] = None + price_usd: Optional[float] = None + stock: Optional[int] = None + is_available: Optional[bool] = None + specs: Optional[dict] = None + images: Optional[List[str]] = None + main_image: Optional[str] = None + tags: Optional[List[str]] = None + highlights: Optional[List[str]] = None + is_featured: Optional[bool] = None + + class Config: + from_attributes = True + + +# =========================================== +# ENDPOINTS +# =========================================== + +@router.get("/") +async def list_products( + category: Optional[str] = Query(None), + brand: Optional[str] = Query(None), + is_available: Optional[bool] = Query(None), + is_featured: Optional[bool] = Query(None), + min_price: Optional[float] = Query(None), + max_price: Optional[float] = Query(None), + limit: int = Query(50, le=100), + offset: int = Query(0), + db: Session = Depends(get_db) +): + """Listar productos con filtros.""" + query = db.query(Product) + + if category: + query = query.filter(Product.category == category) + if brand: + query = query.filter(Product.brand == brand) + if is_available is not None: + query = query.filter(Product.is_available == is_available) + if is_featured is not None: + query = query.filter(Product.is_featured == is_featured) + if min_price is not None: + query = query.filter(Product.price >= min_price) + if max_price is not None: + query = query.filter(Product.price <= max_price) + + products = query.order_by(Product.created_at.desc()).offset(offset).limit(limit).all() + return [p.to_dict() for p in products] + + +@router.get("/categories") +async def list_categories(db: Session = Depends(get_db)): + """Listar categorías únicas de productos.""" + categories = db.query(Product.category).distinct().all() + return [c[0] for c in categories if c[0]] + + +@router.get("/featured") +async def list_featured_products(db: Session = Depends(get_db)): + """Listar productos destacados.""" + products = db.query(Product).filter( + Product.is_featured == True, + Product.is_available == True + ).all() + return [p.to_dict() for p in products] + + +@router.get("/{product_id}") +async def get_product(product_id: int, db: Session = Depends(get_db)): + """Obtener un producto por ID.""" + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException(status_code=404, detail="Producto no encontrado") + return product.to_dict() + + +@router.post("/") +async def create_product(product_data: ProductCreate, db: Session = Depends(get_db)): + """Crear un nuevo producto.""" + product = Product(**product_data.dict()) + db.add(product) + db.commit() + db.refresh(product) + return product.to_dict() + + +@router.put("/{product_id}") +async def update_product(product_id: int, product_data: ProductUpdate, db: Session = Depends(get_db)): + """Actualizar un producto.""" + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException(status_code=404, detail="Producto no encontrado") + + update_data = product_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(product, field, value) + + product.updated_at = datetime.utcnow() + db.commit() + db.refresh(product) + return product.to_dict() + + +@router.delete("/{product_id}") +async def delete_product(product_id: int, db: Session = Depends(get_db)): + """Eliminar un producto.""" + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException(status_code=404, detail="Producto no encontrado") + + db.delete(product) + db.commit() + return {"message": "Producto eliminado", "product_id": product_id} + + +@router.post("/{product_id}/toggle-featured") +async def toggle_featured(product_id: int, db: Session = Depends(get_db)): + """Alternar estado de producto destacado.""" + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException(status_code=404, detail="Producto no encontrado") + + product.is_featured = not product.is_featured + db.commit() + + return { + "message": f"Producto {'destacado' if product.is_featured else 'no destacado'}", + "is_featured": product.is_featured + } diff --git a/app/api/routes/services.py b/app/api/routes/services.py new file mode 100644 index 0000000..7eb4a7c --- /dev/null +++ b/app/api/routes/services.py @@ -0,0 +1,174 @@ +""" +API Routes para gestión de Servicios. +""" + +from datetime import datetime +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.database import get_db +from app.models.service import Service + +router = APIRouter() + + +# =========================================== +# SCHEMAS +# =========================================== + +class ServiceCreate(BaseModel): + name: str + description: str + short_description: Optional[str] = None + category: str + target_sectors: Optional[List[str]] = None + benefits: Optional[List[str]] = None + features: Optional[List[str]] = None + case_studies: Optional[List[dict]] = None + icon: Optional[str] = None + images: Optional[List[str]] = None + main_image: Optional[str] = None + price_range: Optional[str] = None + has_free_demo: bool = False + tags: Optional[List[str]] = None + call_to_action: Optional[str] = None + is_featured: bool = False + + class Config: + from_attributes = True + + +class ServiceUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + short_description: Optional[str] = None + category: Optional[str] = None + target_sectors: Optional[List[str]] = None + benefits: Optional[List[str]] = None + features: Optional[List[str]] = None + case_studies: Optional[List[dict]] = None + icon: Optional[str] = None + images: Optional[List[str]] = None + main_image: Optional[str] = None + price_range: Optional[str] = None + has_free_demo: Optional[bool] = None + tags: Optional[List[str]] = None + call_to_action: Optional[str] = None + is_active: Optional[bool] = None + is_featured: Optional[bool] = None + + class Config: + from_attributes = True + + +# =========================================== +# ENDPOINTS +# =========================================== + +@router.get("/") +async def list_services( + category: Optional[str] = Query(None), + target_sector: Optional[str] = Query(None), + is_active: Optional[bool] = Query(True), + is_featured: Optional[bool] = Query(None), + limit: int = Query(50, le=100), + offset: int = Query(0), + db: Session = Depends(get_db) +): + """Listar servicios con filtros.""" + query = db.query(Service) + + if category: + query = query.filter(Service.category == category) + if is_active is not None: + query = query.filter(Service.is_active == is_active) + if is_featured is not None: + query = query.filter(Service.is_featured == is_featured) + if target_sector: + query = query.filter(Service.target_sectors.contains([target_sector])) + + services = query.order_by(Service.created_at.desc()).offset(offset).limit(limit).all() + return [s.to_dict() for s in services] + + +@router.get("/categories") +async def list_categories(db: Session = Depends(get_db)): + """Listar categorías únicas de servicios.""" + categories = db.query(Service.category).distinct().all() + return [c[0] for c in categories if c[0]] + + +@router.get("/featured") +async def list_featured_services(db: Session = Depends(get_db)): + """Listar servicios destacados.""" + services = db.query(Service).filter( + Service.is_featured == True, + Service.is_active == True + ).all() + return [s.to_dict() for s in services] + + +@router.get("/{service_id}") +async def get_service(service_id: int, db: Session = Depends(get_db)): + """Obtener un servicio por ID.""" + service = db.query(Service).filter(Service.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="Servicio no encontrado") + return service.to_dict() + + +@router.post("/") +async def create_service(service_data: ServiceCreate, db: Session = Depends(get_db)): + """Crear un nuevo servicio.""" + service = Service(**service_data.dict()) + db.add(service) + db.commit() + db.refresh(service) + return service.to_dict() + + +@router.put("/{service_id}") +async def update_service(service_id: int, service_data: ServiceUpdate, db: Session = Depends(get_db)): + """Actualizar un servicio.""" + service = db.query(Service).filter(Service.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="Servicio no encontrado") + + update_data = service_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(service, field, value) + + service.updated_at = datetime.utcnow() + db.commit() + db.refresh(service) + return service.to_dict() + + +@router.delete("/{service_id}") +async def delete_service(service_id: int, db: Session = Depends(get_db)): + """Eliminar un servicio.""" + service = db.query(Service).filter(Service.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="Servicio no encontrado") + + db.delete(service) + db.commit() + return {"message": "Servicio eliminado", "service_id": service_id} + + +@router.post("/{service_id}/toggle-featured") +async def toggle_featured(service_id: int, db: Session = Depends(get_db)): + """Alternar estado de servicio destacado.""" + service = db.query(Service).filter(Service.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="Servicio no encontrado") + + service.is_featured = not service.is_featured + db.commit() + + return { + "message": f"Servicio {'destacado' if service.is_featured else 'no destacado'}", + "is_featured": service.is_featured + } diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..3e83c63 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core module diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..de0f58a --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,60 @@ +""" +Configuración central del sistema. +Carga variables de entorno y define settings globales. +""" + +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + """Configuración de la aplicación.""" + + # Aplicación + APP_NAME: str = "social-media-automation" + APP_ENV: str = "development" + DEBUG: bool = True + SECRET_KEY: str = "change-this-in-production" + + # Base de datos + DATABASE_URL: str = "postgresql://social_user:social_pass@localhost:5432/social_automation" + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # DeepSeek API + DEEPSEEK_API_KEY: Optional[str] = None + DEEPSEEK_BASE_URL: str = "https://api.deepseek.com/v1" + + # X (Twitter) API + X_API_KEY: Optional[str] = None + X_API_SECRET: Optional[str] = None + X_ACCESS_TOKEN: Optional[str] = None + X_ACCESS_TOKEN_SECRET: Optional[str] = None + X_BEARER_TOKEN: Optional[str] = None + + # Meta API (Facebook, Instagram, Threads) + META_APP_ID: Optional[str] = None + META_APP_SECRET: Optional[str] = None + META_ACCESS_TOKEN: Optional[str] = None + FACEBOOK_PAGE_ID: Optional[str] = None + INSTAGRAM_ACCOUNT_ID: Optional[str] = None + THREADS_USER_ID: Optional[str] = None + + # Información del negocio + BUSINESS_NAME: str = "Consultoría AS" + BUSINESS_LOCATION: str = "Tijuana, México" + BUSINESS_WEBSITE: str = "https://consultoria-as.com" + CONTENT_TONE: str = "profesional pero cercano, educativo, orientado a soluciones" + + # Notificaciones + TELEGRAM_BOT_TOKEN: Optional[str] = None + TELEGRAM_CHAT_ID: Optional[str] = None + + class Config: + env_file = ".env" + case_sensitive = True + + +# Instancia global de configuración +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..7702e0e --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,31 @@ +""" +Configuración de la base de datos PostgreSQL. +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +from app.core.config import settings + +# Crear engine de SQLAlchemy +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20 +) + +# Crear sesión +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base para modelos +Base = declarative_base() + + +def get_db(): + """Dependency para obtener sesión de base de datos.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..fcff4cc --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,78 @@ +""" +Configuración de seguridad y autenticación. +""" + +from datetime import datetime, timedelta +from typing import Optional + +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from app.core.config import settings + +# Configuración de hashing de contraseñas +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Configuración de JWT +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 horas + +# Security scheme +security = HTTPBearer() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verificar contraseña contra hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Generar hash de contraseña.""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Crear token JWT.""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + return encoded_jwt + + +def decode_token(token: str) -> dict: + """Decodificar y validar token JWT.""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token inválido o expirado", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> dict: + """Obtener usuario actual desde el token.""" + token = credentials.credentials + payload = decode_token(token) + + username = payload.get("sub") + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token inválido" + ) + + return {"username": username} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..66262cc --- /dev/null +++ b/app/main.py @@ -0,0 +1,71 @@ +""" +Social Media Automation - Consultoría AS +========================================= +Sistema automatizado para la creación y publicación de contenido +en redes sociales (X, Threads, Instagram, Facebook). +""" + +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 +from app.core.config import settings +from app.core.database import engine +from app.models import Base + +# Crear tablas en la base de datos +Base.metadata.create_all(bind=engine) + +# Inicializar aplicación FastAPI +app = FastAPI( + title="Social Media Automation", + description="Sistema de automatización de redes sociales para Consultoría AS", + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", +) + +# Configurar CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # En producción, especificar dominios + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Montar archivos estáticos +app.mount("/static", StaticFiles(directory="dashboard/static"), name="static") + +# Registrar rutas +app.include_router(dashboard.router, prefix="", tags=["Dashboard"]) +app.include_router(posts.router, prefix="/api/posts", tags=["Posts"]) +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.get("/api/health") +async def health_check(): + """Verificar estado del sistema.""" + return { + "status": "healthy", + "app": settings.APP_NAME, + "version": "1.0.0" + } + + +@app.get("/api/stats") +async def get_stats(): + """Obtener estadísticas generales del sistema.""" + # TODO: Implementar estadísticas reales desde la BD + return { + "posts_today": 0, + "posts_week": 0, + "posts_month": 0, + "pending_approval": 0, + "scheduled": 0, + "interactions_pending": 0 + } diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..3cae731 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,24 @@ +""" +Modelos de base de datos SQLAlchemy. +""" + +from app.core.database import Base + +from app.models.product import Product +from app.models.service import Service +from app.models.tip_template import TipTemplate +from app.models.post import Post +from app.models.content_calendar import ContentCalendar +from app.models.image_template import ImageTemplate +from app.models.interaction import Interaction + +__all__ = [ + "Base", + "Product", + "Service", + "TipTemplate", + "Post", + "ContentCalendar", + "ImageTemplate", + "Interaction" +] diff --git a/app/models/content_calendar.py b/app/models/content_calendar.py new file mode 100644 index 0000000..04ae8bd --- /dev/null +++ b/app/models/content_calendar.py @@ -0,0 +1,66 @@ +""" +Modelo de ContentCalendar - Calendario de publicación. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Time, Boolean, DateTime +from sqlalchemy.dialects.postgresql import ARRAY + +from app.core.database import Base + + +class ContentCalendar(Base): + """Modelo para el calendario de contenido.""" + + __tablename__ = "content_calendar" + + id = Column(Integer, primary_key=True, index=True) + + # Programación + day_of_week = Column(Integer, nullable=False) # 0=Lunes, 6=Domingo + time = Column(Time, nullable=False) # Hora de publicación + + # Tipo de contenido + content_type = Column(String(50), nullable=False) + # Tipos: tip_tech, dato_curioso, producto, servicio, etc. + + # Plataformas + platforms = Column(ARRAY(String), nullable=False) + # Ejemplo: ["x", "threads", "instagram"] + + # Configuración + is_active = Column(Boolean, default=True) + requires_approval = Column(Boolean, default=False) + + # Restricciones opcionales + category_filter = Column(String(100), nullable=True) # Solo tips de esta categoría + priority = Column(Integer, default=0) # Mayor = más prioritario + + # Descripción + description = Column(String(255), nullable=True) + # Ejemplo: "Tip tech diario matutino" + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario.""" + days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"] + return { + "id": self.id, + "day_of_week": self.day_of_week, + "day_name": days[self.day_of_week] if 0 <= self.day_of_week <= 6 else "Desconocido", + "time": self.time.strftime("%H:%M") if self.time else None, + "content_type": self.content_type, + "platforms": self.platforms, + "is_active": self.is_active, + "requires_approval": self.requires_approval, + "category_filter": self.category_filter, + "priority": self.priority, + "description": self.description, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/app/models/image_template.py b/app/models/image_template.py new file mode 100644 index 0000000..c6be86e --- /dev/null +++ b/app/models/image_template.py @@ -0,0 +1,75 @@ +""" +Modelo de ImageTemplate - Plantillas para generar imágenes. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.dialects.postgresql import ARRAY + +from app.core.database import Base + + +class ImageTemplate(Base): + """Modelo para plantillas de imágenes.""" + + __tablename__ = "image_templates" + + id = Column(Integer, primary_key=True, index=True) + + # Información básica + name = Column(String(100), nullable=False, index=True) + description = Column(String(255), nullable=True) + + # Categoría + category = Column(String(50), nullable=False) + # Categorías: tip, producto, servicio, promocion, etc. + + # Archivo de plantilla + template_file = Column(String(255), nullable=False) # Ruta al archivo HTML/template + + # Variables que acepta la plantilla + variables = Column(ARRAY(String), nullable=False) + # Ejemplo: ["titulo", "contenido", "hashtags", "logo"] + + # Configuración de diseño + design_config = Column(JSON, nullable=True) + # Ejemplo: { + # "width": 1080, + # "height": 1080, + # "background_color": "#1a1a2e", + # "accent_color": "#d4a574", + # "font_family": "Inter" + # } + + # Tamaños de salida + output_sizes = Column(JSON, nullable=True) + # Ejemplo: { + # "instagram": {"width": 1080, "height": 1080}, + # "x": {"width": 1200, "height": 675}, + # "facebook": {"width": 1200, "height": 630} + # } + + # Estado + is_active = Column(Boolean, default=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario.""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "category": self.category, + "template_file": self.template_file, + "variables": self.variables, + "design_config": self.design_config, + "output_sizes": self.output_sizes, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/app/models/interaction.py b/app/models/interaction.py new file mode 100644 index 0000000..edb5d1e --- /dev/null +++ b/app/models/interaction.py @@ -0,0 +1,87 @@ +""" +Modelo de Interaction - Interacciones en redes sociales. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey + +from app.core.database import Base + + +class Interaction(Base): + """Modelo para interacciones (comentarios, DMs, menciones).""" + + __tablename__ = "interactions" + + id = Column(Integer, primary_key=True, index=True) + + # Plataforma + platform = Column(String(50), nullable=False, index=True) + # Valores: x, threads, instagram, facebook + + # Tipo de interacción + interaction_type = Column(String(50), nullable=False, index=True) + # Tipos: comment, reply, dm, mention, like, repost, quote + + # Post relacionado (si aplica) + post_id = Column(Integer, ForeignKey("posts.id"), nullable=True) + + # ID externo en la plataforma + external_id = Column(String(100), nullable=False, unique=True) + external_post_id = Column(String(100), nullable=True) # ID del post en la plataforma + + # Autor de la interacción + author_username = Column(String(100), nullable=False) + author_name = Column(String(255), nullable=True) + author_profile_url = Column(String(500), nullable=True) + author_avatar_url = Column(String(500), nullable=True) + + # Contenido + content = Column(Text, nullable=True) + + # Respuesta + responded = Column(Boolean, default=False, index=True) + response_content = Column(Text, nullable=True) + responded_at = Column(DateTime, nullable=True) + response_external_id = Column(String(100), nullable=True) + + # Clasificación + is_lead = Column(Boolean, default=False) # Potencial cliente + sentiment = Column(String(50), nullable=True) # positive, negative, neutral + priority = Column(Integer, default=0) # 0=normal, 1=importante, 2=urgente + + # Estado + is_read = Column(Boolean, default=False) + is_archived = Column(Boolean, default=False) + + # Timestamps + interaction_at = Column(DateTime, nullable=False) # Cuándo ocurrió en la plataforma + created_at = Column(DateTime, default=datetime.utcnow) # Cuándo se guardó aquí + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario.""" + return { + "id": self.id, + "platform": self.platform, + "interaction_type": self.interaction_type, + "post_id": self.post_id, + "external_id": self.external_id, + "author_username": self.author_username, + "author_name": self.author_name, + "author_profile_url": self.author_profile_url, + "author_avatar_url": self.author_avatar_url, + "content": self.content, + "responded": self.responded, + "response_content": self.response_content, + "responded_at": self.responded_at.isoformat() if self.responded_at else None, + "is_lead": self.is_lead, + "sentiment": self.sentiment, + "priority": self.priority, + "is_read": self.is_read, + "interaction_at": self.interaction_at.isoformat() if self.interaction_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/app/models/post.py b/app/models/post.py new file mode 100644 index 0000000..7b3ea97 --- /dev/null +++ b/app/models/post.py @@ -0,0 +1,134 @@ +""" +Modelo de Post - Posts generados y programados. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON, Enum +from sqlalchemy.dialects.postgresql import ARRAY +import enum + +from app.core.database import Base + + +class PostStatus(enum.Enum): + """Estados posibles de un post.""" + DRAFT = "draft" + PENDING_APPROVAL = "pending_approval" + APPROVED = "approved" + SCHEDULED = "scheduled" + PUBLISHING = "publishing" + PUBLISHED = "published" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ContentType(enum.Enum): + """Tipos de contenido.""" + TIP_TECH = "tip_tech" + DATO_CURIOSO = "dato_curioso" + FRASE_MOTIVACIONAL = "frase_motivacional" + EFEMERIDE = "efemeride" + PRODUCTO = "producto" + SERVICIO = "servicio" + HILO_EDUCATIVO = "hilo_educativo" + CASO_EXITO = "caso_exito" + PROMOCION = "promocion" + ANUNCIO = "anuncio" + MANUAL = "manual" + + +class Post(Base): + """Modelo para posts de redes sociales.""" + + __tablename__ = "posts" + + id = Column(Integer, primary_key=True, index=True) + + # Contenido + content = Column(Text, nullable=False) + content_type = Column(String(50), nullable=False, index=True) + + # Contenido adaptado por plataforma (opcional) + content_x = Column(Text, nullable=True) # Versión para X (280 chars) + content_threads = Column(Text, nullable=True) + content_instagram = Column(Text, nullable=True) + content_facebook = Column(Text, nullable=True) + + # Plataformas destino + platforms = Column(ARRAY(String), nullable=False) + # Ejemplo: ["x", "threads", "instagram", "facebook"] + + # Estado y programación + status = Column(String(50), default="draft", index=True) + scheduled_at = Column(DateTime, nullable=True, index=True) + published_at = Column(DateTime, nullable=True) + + # Imagen + image_url = Column(String(500), nullable=True) + image_template_id = Column(Integer, ForeignKey("image_templates.id"), nullable=True) + + # IDs de publicación en cada plataforma + platform_post_ids = Column(JSON, nullable=True) + # Ejemplo: {"x": "123456", "instagram": "789012", ...} + + # Errores de publicación + error_message = Column(Text, nullable=True) + retry_count = Column(Integer, default=0) + + # Aprobación + approval_required = Column(Boolean, default=False) + approved_by = Column(String(100), nullable=True) + approved_at = Column(DateTime, nullable=True) + + # Relaciones con contenido fuente + product_id = Column(Integer, ForeignKey("products.id"), nullable=True) + service_id = Column(Integer, ForeignKey("services.id"), nullable=True) + tip_template_id = Column(Integer, ForeignKey("tip_templates.id"), nullable=True) + + # Metadatos + hashtags = Column(ARRAY(String), nullable=True) + mentions = Column(ARRAY(String), nullable=True) + + # Métricas (actualizadas después de publicar) + metrics = Column(JSON, nullable=True) + # Ejemplo: {"likes": 10, "retweets": 5, "comments": 3} + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario.""" + return { + "id": self.id, + "content": self.content, + "content_type": self.content_type, + "content_x": self.content_x, + "content_threads": self.content_threads, + "content_instagram": self.content_instagram, + "content_facebook": self.content_facebook, + "platforms": self.platforms, + "status": self.status, + "scheduled_at": self.scheduled_at.isoformat() if self.scheduled_at else None, + "published_at": self.published_at.isoformat() if self.published_at else None, + "image_url": self.image_url, + "platform_post_ids": self.platform_post_ids, + "error_message": self.error_message, + "approval_required": self.approval_required, + "hashtags": self.hashtags, + "metrics": self.metrics, + "created_at": self.created_at.isoformat() if self.created_at else None + } + + def get_content_for_platform(self, platform: str) -> str: + """Obtener contenido adaptado para una plataforma específica.""" + platform_content = { + "x": self.content_x, + "threads": self.content_threads, + "instagram": self.content_instagram, + "facebook": self.content_facebook + } + return platform_content.get(platform) or self.content diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..dfd35d8 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,80 @@ +""" +Modelo de Producto - Catálogo de equipos de cómputo e impresoras 3D. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, Float, Boolean, DateTime, JSON +from sqlalchemy.dialects.postgresql import ARRAY + +from app.core.database import Base + + +class Product(Base): + """Modelo para productos del catálogo.""" + + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True) + + # Información básica + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + short_description = Column(String(500), nullable=True) # Para posts + + # Categorización + category = Column(String(100), nullable=False, index=True) # laptop, desktop, impresora_3d, etc. + subcategory = Column(String(100), nullable=True) + brand = Column(String(100), nullable=True) + model = Column(String(100), nullable=True) + + # Precio y stock + price = Column(Float, nullable=False) + price_usd = Column(Float, nullable=True) # Precio en dólares (opcional) + stock = Column(Integer, default=0) + is_available = Column(Boolean, default=True) + + # Especificaciones técnicas (JSON flexible) + specs = Column(JSON, nullable=True) + # Ejemplo: {"cpu": "Intel i7", "ram": "16GB", "storage": "512GB SSD"} + + # Imágenes + images = Column(ARRAY(String), nullable=True) # URLs de imágenes + main_image = Column(String(500), nullable=True) + + # SEO y marketing + tags = Column(ARRAY(String), nullable=True) # Tags para búsqueda + highlights = Column(ARRAY(String), nullable=True) # Puntos destacados + + # Control de publicación + is_featured = Column(Boolean, default=False) # Producto destacado + last_posted_at = Column(DateTime, nullable=True) # Última vez que se publicó + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario.""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "short_description": self.short_description, + "category": self.category, + "subcategory": self.subcategory, + "brand": self.brand, + "model": self.model, + "price": self.price, + "stock": self.stock, + "is_available": self.is_available, + "specs": self.specs, + "images": self.images, + "main_image": self.main_image, + "tags": self.tags, + "highlights": self.highlights, + "is_featured": self.is_featured, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/app/models/service.py b/app/models/service.py new file mode 100644 index 0000000..787f669 --- /dev/null +++ b/app/models/service.py @@ -0,0 +1,90 @@ +""" +Modelo de Servicio - Servicios de consultoría de Consultoría AS. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON +from sqlalchemy.dialects.postgresql import ARRAY + +from app.core.database import Base + + +class Service(Base): + """Modelo para servicios de consultoría.""" + + __tablename__ = "services" + + id = Column(Integer, primary_key=True, index=True) + + # Información básica + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=False) + short_description = Column(String(500), nullable=True) # Para posts + + # Categorización + category = Column(String(100), nullable=False, index=True) + # Categorías: ia, automatizacion, desarrollo, iot, voip, crm, etc. + + # Sectores objetivo + target_sectors = Column(ARRAY(String), nullable=True) + # Ejemplo: ["hoteles", "construccion", "logistica", "retail"] + + # Beneficios y características + benefits = Column(ARRAY(String), nullable=True) + # Ejemplo: ["Ahorro de tiempo", "Reducción de errores", "24/7"] + + features = Column(ARRAY(String), nullable=True) + # Características técnicas del servicio + + # Casos de éxito + case_studies = Column(JSON, nullable=True) + # Ejemplo: [{"client": "Hotel X", "result": "50% menos tiempo en reservas"}] + + # Imágenes e íconos + icon = Column(String(100), nullable=True) # Nombre del ícono + images = Column(ARRAY(String), nullable=True) + main_image = Column(String(500), nullable=True) + + # Precios (opcional, puede ser "cotizar") + price_range = Column(String(100), nullable=True) # "Desde $5,000 MXN" + has_free_demo = Column(Boolean, default=False) + + # SEO y marketing + tags = Column(ARRAY(String), nullable=True) + call_to_action = Column(String(255), nullable=True) # "Agenda una demo" + + # Control de publicación + is_active = Column(Boolean, default=True) + is_featured = Column(Boolean, default=False) + last_posted_at = Column(DateTime, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario.""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "short_description": self.short_description, + "category": self.category, + "target_sectors": self.target_sectors, + "benefits": self.benefits, + "features": self.features, + "case_studies": self.case_studies, + "icon": self.icon, + "images": self.images, + "main_image": self.main_image, + "price_range": self.price_range, + "has_free_demo": self.has_free_demo, + "tags": self.tags, + "call_to_action": self.call_to_action, + "is_active": self.is_active, + "is_featured": self.is_featured, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/app/models/tip_template.py b/app/models/tip_template.py new file mode 100644 index 0000000..069951e --- /dev/null +++ b/app/models/tip_template.py @@ -0,0 +1,77 @@ +""" +Modelo de TipTemplate - Banco de tips para generar contenido automático. +""" + +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime +from sqlalchemy.dialects.postgresql import ARRAY + +from app.core.database import Base + + +class TipTemplate(Base): + """Modelo para templates de tips tech.""" + + __tablename__ = "tip_templates" + + id = Column(Integer, primary_key=True, index=True) + + # Categoría del tip + category = Column(String(100), nullable=False, index=True) + # Categorías: hardware, software, seguridad, productividad, ia, redes, etc. + + subcategory = Column(String(100), nullable=True) + + # Contenido del tip + title = Column(String(255), nullable=False) # Título corto + template = Column(Text, nullable=False) # Template con variables + + # Variables que se pueden reemplazar + # Ejemplo: ["sistema_operativo", "herramienta", "beneficio"] + variables = Column(ARRAY(String), nullable=True) + + # Variaciones del tip (diferentes formas de decirlo) + variations = Column(ARRAY(String), nullable=True) + + # Metadatos + difficulty = Column(String(50), nullable=True) # basico, intermedio, avanzado + target_audience = Column(String(100), nullable=True) # empresas, desarrolladores, general + + # Control de uso + used_count = Column(Integer, default=0) + last_used = Column(DateTime, nullable=True) + + # Plataformas recomendadas + recommended_platforms = Column(ARRAY(String), nullable=True) + # Ejemplo: ["x", "threads", "instagram"] + + # Estado + is_active = Column(Boolean, default=True) + is_evergreen = Column(Boolean, default=True) # ¿Siempre relevante? + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convertir a diccionario.""" + return { + "id": self.id, + "category": self.category, + "subcategory": self.subcategory, + "title": self.title, + "template": self.template, + "variables": self.variables, + "variations": self.variations, + "difficulty": self.difficulty, + "target_audience": self.target_audience, + "used_count": self.used_count, + "last_used": self.last_used.isoformat() if self.last_used else None, + "recommended_platforms": self.recommended_platforms, + "is_active": self.is_active, + "is_evergreen": self.is_evergreen, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/app/prompts/system_prompt.txt b/app/prompts/system_prompt.txt new file mode 100644 index 0000000..9d03a01 --- /dev/null +++ b/app/prompts/system_prompt.txt @@ -0,0 +1,44 @@ +Eres el Community Manager de Consultoría AS, una empresa de tecnología ubicada en Tijuana, México. + +SOBRE LA EMPRESA: +- Especializada en soluciones de IA, automatización y transformación digital +- Vende equipos de cómputo e impresoras 3D +- Más de 6 años de experiencia +- Soporte 24/7 +- Sitio web: https://consultoria-as.com + +SERVICIOS PRINCIPALES: +- Integración de IA y automatización inteligente +- Sistemas de gestión para hoteles (nómina, inventario, dashboards) +- Software para construcción (gestión de proyectos, diagramas Gantt) +- Chatbots para WhatsApp e Instagram +- Soluciones IoT +- Monitoreo GPS de flotillas +- Sistemas CRM personalizados +- Comunicaciones VoIP empresariales + +PRODUCTOS: +- Laptops y computadoras de escritorio +- Impresoras 3D +- Accesorios y periféricos + +TONO DE COMUNICACIÓN: +- Profesional pero cercano +- Educativo y de valor +- Orientado a soluciones, no a vender directamente +- Uso moderado de emojis (1-3 por post) +- Hashtags relevantes (máximo 3-5) + +ESTILO INSPIRADO EN: +- @midudev: Tips cortos y accionables +- @MoureDev: Contenido educativo y de comunidad +- @SoyDalto: Accesible para todos los niveles +- @SuGE3K: Tips prácticos y herramientas + +REGLAS IMPORTANTES: +- Nunca uses lenguaje ofensivo +- No hagas promesas exageradas +- Sé honesto y transparente +- Enfócate en ayudar y educar +- Adapta el contenido a cada plataforma +- No inventes información técnica diff --git a/app/publishers/__init__.py b/app/publishers/__init__.py new file mode 100644 index 0000000..8fa5984 --- /dev/null +++ b/app/publishers/__init__.py @@ -0,0 +1,35 @@ +""" +Publishers para cada plataforma de redes sociales. +""" + +from app.publishers.base import BasePublisher +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 + + +def get_publisher(platform: str) -> BasePublisher: + """Obtener el publisher para una plataforma específica.""" + publishers = { + "x": XPublisher(), + "threads": ThreadsPublisher(), + "instagram": InstagramPublisher(), + "facebook": FacebookPublisher() + } + + publisher = publishers.get(platform.lower()) + if not publisher: + raise ValueError(f"Plataforma no soportada: {platform}") + + return publisher + + +__all__ = [ + "BasePublisher", + "XPublisher", + "ThreadsPublisher", + "InstagramPublisher", + "FacebookPublisher", + "get_publisher" +] diff --git a/app/publishers/base.py b/app/publishers/base.py new file mode 100644 index 0000000..78ae1c9 --- /dev/null +++ b/app/publishers/base.py @@ -0,0 +1,74 @@ +""" +Clase base para publishers de redes sociales. +""" + +from abc import ABC, abstractmethod +from typing import Optional, List, Dict +from dataclasses import dataclass + + +@dataclass +class PublishResult: + """Resultado de una publicación.""" + success: bool + post_id: Optional[str] = None + url: Optional[str] = None + error_message: Optional[str] = None + + +class BasePublisher(ABC): + """Clase base abstracta para publishers.""" + + platform: str = "base" + + @abstractmethod + async def publish( + self, + content: str, + image_path: Optional[str] = None + ) -> PublishResult: + """Publicar contenido en la plataforma.""" + pass + + @abstractmethod + async def publish_thread( + self, + posts: List[str], + images: Optional[List[str]] = None + ) -> PublishResult: + """Publicar un hilo de posts.""" + pass + + @abstractmethod + async def reply( + self, + post_id: str, + content: str + ) -> PublishResult: + """Responder a un post.""" + pass + + @abstractmethod + async def like(self, post_id: str) -> bool: + """Dar like a un post.""" + pass + + @abstractmethod + async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]: + """Obtener menciones recientes.""" + pass + + @abstractmethod + async def get_comments(self, post_id: str) -> List[Dict]: + """Obtener comentarios de un post.""" + pass + + @abstractmethod + async def delete(self, post_id: str) -> bool: + """Eliminar un post.""" + pass + + def validate_content(self, content: str) -> bool: + """Validar que el contenido cumple con los límites de la plataforma.""" + # Implementar en subclases según límites específicos + return True diff --git a/app/publishers/facebook_publisher.py b/app/publishers/facebook_publisher.py new file mode 100644 index 0000000..ff4120f --- /dev/null +++ b/app/publishers/facebook_publisher.py @@ -0,0 +1,252 @@ +""" +Publisher para Facebook Pages (Meta Graph API). +""" + +from typing import Optional, List, Dict +import httpx + +from app.core.config import settings +from app.publishers.base import BasePublisher, PublishResult + + +class FacebookPublisher(BasePublisher): + """Publisher para Facebook Pages usando Meta Graph API.""" + + platform = "facebook" + char_limit = 63206 # Límite real de Facebook + base_url = "https://graph.facebook.com/v18.0" + + def __init__(self): + self.access_token = settings.META_ACCESS_TOKEN + self.page_id = settings.FACEBOOK_PAGE_ID + + def validate_content(self, content: str) -> bool: + """Validar longitud del post.""" + return len(content) <= self.char_limit + + async def publish( + self, + content: str, + image_path: Optional[str] = None + ) -> PublishResult: + """Publicar en Facebook Page.""" + if not self.access_token or not self.page_id: + return PublishResult( + success=False, + error_message="Credenciales de Facebook no configuradas" + ) + + try: + async with httpx.AsyncClient() as client: + if image_path: + # Publicar con imagen + url = f"{self.base_url}/{self.page_id}/photos" + payload = { + "caption": content, + "url": image_path, # URL pública de la imagen + "access_token": self.access_token + } + else: + # Publicar solo texto + url = f"{self.base_url}/{self.page_id}/feed" + payload = { + "message": content, + "access_token": self.access_token + } + + response = await client.post(url, data=payload) + response.raise_for_status() + post_id = response.json().get("id") + + return PublishResult( + success=True, + post_id=post_id, + url=f"https://www.facebook.com/{post_id}" + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def publish_thread( + self, + posts: List[str], + images: Optional[List[str]] = None + ) -> PublishResult: + """Publicar como un solo post largo en Facebook.""" + # Facebook no tiene threads, concatenamos el contenido + combined_content = "\n\n".join(posts) + + # Usar la primera imagen si existe + image = images[0] if images else None + + return await self.publish(combined_content, image) + + async def reply( + self, + post_id: str, + content: str + ) -> PublishResult: + """Responder a un comentario en Facebook.""" + if not self.access_token: + return PublishResult( + success=False, + error_message="Token de acceso no configurado" + ) + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{post_id}/comments" + payload = { + "message": content, + "access_token": self.access_token + } + + response = await client.post(url, data=payload) + response.raise_for_status() + comment_id = response.json().get("id") + + return PublishResult( + success=True, + post_id=comment_id + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def like(self, post_id: str) -> bool: + """Dar like a un post/comentario.""" + if not self.access_token: + return False + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{post_id}/likes" + payload = {"access_token": self.access_token} + + response = await client.post(url, data=payload) + response.raise_for_status() + return response.json().get("success", False) + + except httpx.HTTPError: + return False + + async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]: + """Obtener menciones de la página.""" + if not self.access_token or not self.page_id: + return [] + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{self.page_id}/tagged" + params = { + "fields": "id,message,from,created_time,permalink_url", + "access_token": self.access_token + } + + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + return data.get("data", []) + + except httpx.HTTPError: + return [] + + async def get_comments(self, post_id: str) -> List[Dict]: + """Obtener comentarios de un post.""" + if not self.access_token: + return [] + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{post_id}/comments" + params = { + "fields": "id,message,from,created_time,like_count", + "access_token": self.access_token + } + + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + return data.get("data", []) + + except httpx.HTTPError: + return [] + + async def get_page_messages(self) -> List[Dict]: + """Obtener mensajes de la página (inbox).""" + if not self.access_token or not self.page_id: + return [] + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{self.page_id}/conversations" + params = { + "fields": "id,participants,messages{message,from,created_time}", + "access_token": self.access_token + } + + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + return data.get("data", []) + + except httpx.HTTPError: + return [] + + async def send_message(self, recipient_id: str, message: str) -> PublishResult: + """Enviar mensaje directo a un usuario.""" + if not self.access_token or not self.page_id: + return PublishResult( + success=False, + error_message="Credenciales no configuradas" + ) + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{self.page_id}/messages" + payload = { + "recipient": {"id": recipient_id}, + "message": {"text": message}, + "access_token": self.access_token + } + + response = await client.post(url, json=payload) + response.raise_for_status() + message_id = response.json().get("message_id") + + return PublishResult( + success=True, + post_id=message_id + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def delete(self, post_id: str) -> bool: + """Eliminar un post.""" + if not self.access_token: + return False + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{post_id}" + params = {"access_token": self.access_token} + + response = await client.delete(url, params=params) + response.raise_for_status() + return response.json().get("success", False) + + except httpx.HTTPError: + return False diff --git a/app/publishers/instagram_publisher.py b/app/publishers/instagram_publisher.py new file mode 100644 index 0000000..d58bcd8 --- /dev/null +++ b/app/publishers/instagram_publisher.py @@ -0,0 +1,240 @@ +""" +Publisher para Instagram (Meta Graph API). +""" + +from typing import Optional, List, Dict +import httpx + +from app.core.config import settings +from app.publishers.base import BasePublisher, PublishResult + + +class InstagramPublisher(BasePublisher): + """Publisher para Instagram usando Meta Graph API.""" + + platform = "instagram" + char_limit = 2200 + base_url = "https://graph.facebook.com/v18.0" + + def __init__(self): + self.access_token = settings.META_ACCESS_TOKEN + self.account_id = settings.INSTAGRAM_ACCOUNT_ID + + def validate_content(self, content: str) -> bool: + """Validar longitud del caption.""" + return len(content) <= self.char_limit + + async def publish( + self, + content: str, + image_path: Optional[str] = None + ) -> PublishResult: + """Publicar en Instagram (requiere imagen).""" + if not self.access_token or not self.account_id: + return PublishResult( + success=False, + error_message="Credenciales de Instagram no configuradas" + ) + + if not image_path: + return PublishResult( + success=False, + error_message="Instagram requiere una imagen para publicar" + ) + + try: + async with httpx.AsyncClient() as client: + # Paso 1: Crear contenedor de media + # Nota: La imagen debe estar en una URL pública + create_url = f"{self.base_url}/{self.account_id}/media" + + payload = { + "caption": content, + "image_url": image_path, # Debe ser URL pública + "access_token": self.access_token + } + + response = await client.post(create_url, data=payload) + response.raise_for_status() + container_id = response.json().get("id") + + # Paso 2: Publicar el contenedor + publish_url = f"{self.base_url}/{self.account_id}/media_publish" + publish_payload = { + "creation_id": container_id, + "access_token": self.access_token + } + + response = await client.post(publish_url, data=publish_payload) + response.raise_for_status() + post_id = response.json().get("id") + + return PublishResult( + success=True, + post_id=post_id, + url=f"https://www.instagram.com/p/{post_id}" + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def publish_thread( + self, + posts: List[str], + images: Optional[List[str]] = None + ) -> PublishResult: + """Publicar carrusel en Instagram.""" + if not self.access_token or not self.account_id: + return PublishResult( + success=False, + error_message="Credenciales de Instagram no configuradas" + ) + + if not images or len(images) < 2: + return PublishResult( + success=False, + error_message="Un carrusel de Instagram requiere al menos 2 imágenes" + ) + + try: + async with httpx.AsyncClient() as client: + # Paso 1: Crear contenedores para cada imagen + children_ids = [] + + for image_url in images[:10]: # Máximo 10 imágenes + create_url = f"{self.base_url}/{self.account_id}/media" + payload = { + "image_url": image_url, + "is_carousel_item": True, + "access_token": self.access_token + } + + response = await client.post(create_url, data=payload) + response.raise_for_status() + children_ids.append(response.json().get("id")) + + # Paso 2: Crear contenedor del carrusel + carousel_url = f"{self.base_url}/{self.account_id}/media" + carousel_payload = { + "media_type": "CAROUSEL", + "caption": posts[0] if posts else "", + "children": ",".join(children_ids), + "access_token": self.access_token + } + + response = await client.post(carousel_url, data=carousel_payload) + response.raise_for_status() + carousel_id = response.json().get("id") + + # Paso 3: Publicar + publish_url = f"{self.base_url}/{self.account_id}/media_publish" + publish_payload = { + "creation_id": carousel_id, + "access_token": self.access_token + } + + response = await client.post(publish_url, data=publish_payload) + response.raise_for_status() + post_id = response.json().get("id") + + return PublishResult( + success=True, + post_id=post_id + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def reply( + self, + post_id: str, + content: str + ) -> PublishResult: + """Responder a un comentario en Instagram.""" + if not self.access_token: + return PublishResult( + success=False, + error_message="Token de acceso no configurado" + ) + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{post_id}/replies" + payload = { + "message": content, + "access_token": self.access_token + } + + response = await client.post(url, data=payload) + response.raise_for_status() + reply_id = response.json().get("id") + + return PublishResult( + success=True, + post_id=reply_id + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def like(self, post_id: str) -> bool: + """Dar like (no disponible vía API para cuentas de negocio).""" + return False + + async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]: + """Obtener menciones en comentarios e historias.""" + if not self.access_token or not self.account_id: + return [] + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{self.account_id}/tags" + params = { + "fields": "id,caption,media_type,permalink,timestamp,username", + "access_token": self.access_token + } + + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + return data.get("data", []) + + except httpx.HTTPError: + return [] + + async def get_comments(self, post_id: str) -> List[Dict]: + """Obtener comentarios de un post.""" + if not self.access_token: + return [] + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{post_id}/comments" + params = { + "fields": "id,text,username,timestamp,like_count", + "access_token": self.access_token + } + + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + return data.get("data", []) + + except httpx.HTTPError: + return [] + + async def delete(self, post_id: str) -> bool: + """Eliminar un post (no disponible vía API).""" + # La API de Instagram no permite eliminar posts + return False diff --git a/app/publishers/threads_publisher.py b/app/publishers/threads_publisher.py new file mode 100644 index 0000000..2c7a0b1 --- /dev/null +++ b/app/publishers/threads_publisher.py @@ -0,0 +1,227 @@ +""" +Publisher para Threads (Meta). +""" + +from typing import Optional, List, Dict +import httpx + +from app.core.config import settings +from app.publishers.base import BasePublisher, PublishResult + + +class ThreadsPublisher(BasePublisher): + """Publisher para Threads usando Meta Graph API.""" + + platform = "threads" + char_limit = 500 + base_url = "https://graph.threads.net/v1.0" + + def __init__(self): + self.access_token = settings.META_ACCESS_TOKEN + self.user_id = settings.THREADS_USER_ID + + def validate_content(self, content: str) -> bool: + """Validar longitud del post.""" + return len(content) <= self.char_limit + + async def publish( + self, + content: str, + image_path: Optional[str] = None + ) -> PublishResult: + """Publicar en Threads.""" + if not self.access_token or not self.user_id: + return PublishResult( + success=False, + error_message="Credenciales de Threads no configuradas" + ) + + try: + async with httpx.AsyncClient() as client: + # Paso 1: Crear el contenedor del post + create_url = f"{self.base_url}/{self.user_id}/threads" + + payload = { + "text": content, + "media_type": "TEXT", + "access_token": self.access_token + } + + # Si hay imagen, subirla primero + if image_path: + # TODO: Implementar subida de imagen a Threads + payload["media_type"] = "IMAGE" + # payload["image_url"] = uploaded_image_url + + response = await client.post(create_url, data=payload) + response.raise_for_status() + container_id = response.json().get("id") + + # Paso 2: Publicar el contenedor + publish_url = f"{self.base_url}/{self.user_id}/threads_publish" + publish_payload = { + "creation_id": container_id, + "access_token": self.access_token + } + + response = await client.post(publish_url, data=publish_payload) + response.raise_for_status() + post_id = response.json().get("id") + + return PublishResult( + success=True, + post_id=post_id, + url=f"https://www.threads.net/post/{post_id}" + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def publish_thread( + self, + posts: List[str], + images: Optional[List[str]] = None + ) -> PublishResult: + """Publicar un hilo en Threads.""" + if not self.access_token or not self.user_id: + return PublishResult( + success=False, + error_message="Credenciales de Threads no configuradas" + ) + + try: + first_post_id = None + reply_to_id = None + + for i, post in enumerate(posts): + async with httpx.AsyncClient() as client: + create_url = f"{self.base_url}/{self.user_id}/threads" + + payload = { + "text": post, + "media_type": "TEXT", + "access_token": self.access_token + } + + if reply_to_id: + payload["reply_to_id"] = reply_to_id + + response = await client.post(create_url, data=payload) + response.raise_for_status() + container_id = response.json().get("id") + + # Publicar + publish_url = f"{self.base_url}/{self.user_id}/threads_publish" + publish_payload = { + "creation_id": container_id, + "access_token": self.access_token + } + + response = await client.post(publish_url, data=publish_payload) + response.raise_for_status() + post_id = response.json().get("id") + + if i == 0: + first_post_id = post_id + reply_to_id = post_id + + return PublishResult( + success=True, + post_id=first_post_id, + url=f"https://www.threads.net/post/{first_post_id}" + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def reply( + self, + post_id: str, + content: str + ) -> PublishResult: + """Responder a un post en Threads.""" + if not self.access_token or not self.user_id: + return PublishResult( + success=False, + error_message="Credenciales de Threads no configuradas" + ) + + try: + async with httpx.AsyncClient() as client: + create_url = f"{self.base_url}/{self.user_id}/threads" + + payload = { + "text": content, + "media_type": "TEXT", + "reply_to_id": post_id, + "access_token": self.access_token + } + + response = await client.post(create_url, data=payload) + response.raise_for_status() + container_id = response.json().get("id") + + # Publicar + publish_url = f"{self.base_url}/{self.user_id}/threads_publish" + publish_payload = { + "creation_id": container_id, + "access_token": self.access_token + } + + response = await client.post(publish_url, data=publish_payload) + response.raise_for_status() + reply_id = response.json().get("id") + + return PublishResult( + success=True, + post_id=reply_id + ) + + except httpx.HTTPError as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def like(self, post_id: str) -> bool: + """Dar like a un post (no soportado actualmente por la API).""" + # La API de Threads no soporta likes programáticos actualmente + return False + + async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]: + """Obtener menciones (limitado en la API de Threads).""" + # TODO: Implementar cuando la API lo soporte + return [] + + async def get_comments(self, post_id: str) -> List[Dict]: + """Obtener respuestas a un post.""" + if not self.access_token: + return [] + + try: + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{post_id}/replies" + params = { + "access_token": self.access_token, + "fields": "id,text,username,timestamp" + } + + response = await client.get(url, params=params) + response.raise_for_status() + data = response.json() + + return data.get("data", []) + + except httpx.HTTPError: + return [] + + async def delete(self, post_id: str) -> bool: + """Eliminar un post de Threads (no soportado actualmente).""" + # La API de Threads no soporta eliminación actualmente + return False diff --git a/app/publishers/x_publisher.py b/app/publishers/x_publisher.py new file mode 100644 index 0000000..33c8f71 --- /dev/null +++ b/app/publishers/x_publisher.py @@ -0,0 +1,255 @@ +""" +Publisher para X (Twitter). +""" + +from typing import Optional, List, Dict +import tweepy + +from app.core.config import settings +from app.publishers.base import BasePublisher, PublishResult + + +class XPublisher(BasePublisher): + """Publisher para X (Twitter) usando Tweepy.""" + + platform = "x" + char_limit = 280 + + def __init__(self): + self.client = None + self.api = None + self._init_client() + + def _init_client(self): + """Inicializar cliente de Twitter.""" + if not all([ + settings.X_API_KEY, + settings.X_API_SECRET, + settings.X_ACCESS_TOKEN, + settings.X_ACCESS_TOKEN_SECRET + ]): + return + + # Cliente v2 para publicar + self.client = tweepy.Client( + consumer_key=settings.X_API_KEY, + consumer_secret=settings.X_API_SECRET, + access_token=settings.X_ACCESS_TOKEN, + access_token_secret=settings.X_ACCESS_TOKEN_SECRET, + bearer_token=settings.X_BEARER_TOKEN + ) + + # API v1.1 para subir imágenes + auth = tweepy.OAuth1UserHandler( + settings.X_API_KEY, + settings.X_API_SECRET, + settings.X_ACCESS_TOKEN, + settings.X_ACCESS_TOKEN_SECRET + ) + self.api = tweepy.API(auth) + + def validate_content(self, content: str) -> bool: + """Validar longitud del tweet.""" + return len(content) <= self.char_limit + + async def publish( + self, + content: str, + image_path: Optional[str] = None + ) -> PublishResult: + """Publicar un tweet.""" + if not self.client: + return PublishResult( + success=False, + error_message="Cliente de X no configurado" + ) + + try: + media_ids = None + + # Subir imagen si existe + if image_path and self.api: + media = self.api.media_upload(filename=image_path) + media_ids = [media.media_id] + + # Publicar tweet + response = self.client.create_tweet( + text=content, + media_ids=media_ids + ) + + tweet_id = response.data['id'] + return PublishResult( + success=True, + post_id=tweet_id, + url=f"https://x.com/i/web/status/{tweet_id}" + ) + + except tweepy.TweepyException as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def publish_thread( + self, + posts: List[str], + images: Optional[List[str]] = None + ) -> PublishResult: + """Publicar un hilo de tweets.""" + if not self.client: + return PublishResult( + success=False, + error_message="Cliente de X no configurado" + ) + + try: + previous_tweet_id = None + first_tweet_id = None + + for i, post in enumerate(posts): + media_ids = None + + # Subir imagen si existe para este post + if images and i < len(images) and images[i] and self.api: + media = self.api.media_upload(filename=images[i]) + media_ids = [media.media_id] + + # Publicar tweet (como respuesta al anterior si existe) + response = self.client.create_tweet( + text=post, + media_ids=media_ids, + in_reply_to_tweet_id=previous_tweet_id + ) + + previous_tweet_id = response.data['id'] + + if i == 0: + first_tweet_id = previous_tweet_id + + return PublishResult( + success=True, + post_id=first_tweet_id, + url=f"https://x.com/i/web/status/{first_tweet_id}" + ) + + except tweepy.TweepyException as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def reply( + self, + post_id: str, + content: str + ) -> PublishResult: + """Responder a un tweet.""" + if not self.client: + return PublishResult( + success=False, + error_message="Cliente de X no configurado" + ) + + try: + response = self.client.create_tweet( + text=content, + in_reply_to_tweet_id=post_id + ) + + return PublishResult( + success=True, + post_id=response.data['id'] + ) + + except tweepy.TweepyException as e: + return PublishResult( + success=False, + error_message=str(e) + ) + + async def like(self, post_id: str) -> bool: + """Dar like a un tweet.""" + if not self.client: + return False + + try: + self.client.like(post_id) + return True + except tweepy.TweepyException: + return False + + async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]: + """Obtener menciones recientes.""" + if not self.client: + return [] + + try: + # Obtener ID del usuario autenticado + me = self.client.get_me() + user_id = me.data.id + + # Obtener menciones + mentions = self.client.get_users_mentions( + id=user_id, + since_id=since_id, + max_results=50, + tweet_fields=['created_at', 'author_id', 'conversation_id'] + ) + + if not mentions.data: + return [] + + return [ + { + "id": str(tweet.id), + "text": tweet.text, + "author_id": str(tweet.author_id), + "created_at": tweet.created_at.isoformat() if tweet.created_at else None + } + for tweet in mentions.data + ] + + except tweepy.TweepyException: + return [] + + async def get_comments(self, post_id: str) -> List[Dict]: + """Obtener respuestas a un tweet.""" + if not self.client: + return [] + + try: + # Buscar respuestas al tweet + query = f"conversation_id:{post_id}" + tweets = self.client.search_recent_tweets( + query=query, + max_results=50, + tweet_fields=['created_at', 'author_id', 'in_reply_to_user_id'] + ) + + if not tweets.data: + return [] + + return [ + { + "id": str(tweet.id), + "text": tweet.text, + "author_id": str(tweet.author_id), + "created_at": tweet.created_at.isoformat() if tweet.created_at else None + } + for tweet in tweets.data + ] + + except tweepy.TweepyException: + return [] + + async def delete(self, post_id: str) -> bool: + """Eliminar un tweet.""" + if not self.client: + return False + + try: + self.client.delete_tweet(post_id) + return True + except tweepy.TweepyException: + return False diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..0557eb6 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services module diff --git a/app/services/content_generator.py b/app/services/content_generator.py new file mode 100644 index 0000000..a6dc5dd --- /dev/null +++ b/app/services/content_generator.py @@ -0,0 +1,314 @@ +""" +Servicio de generación de contenido con DeepSeek API. +""" + +import json +from typing import Optional, List, Dict +from openai import OpenAI + +from app.core.config import settings + + +class ContentGenerator: + """Generador de contenido usando DeepSeek API.""" + + def __init__(self): + self.client = OpenAI( + api_key=settings.DEEPSEEK_API_KEY, + base_url=settings.DEEPSEEK_BASE_URL + ) + self.model = "deepseek-chat" + + def _get_system_prompt(self) -> str: + """Obtener el prompt del sistema con la personalidad de la marca.""" + return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}. + +SOBRE LA EMPRESA: +- Especializada en soluciones de IA, automatización y transformación digital +- Vende equipos de cómputo e impresoras 3D +- Sitio web: {settings.BUSINESS_WEBSITE} + +TONO DE COMUNICACIÓN: +{settings.CONTENT_TONE} + +ESTILO (inspirado en @midudev, @MoureDev, @SoyDalto): +- Tips cortos y accionables +- Contenido educativo de valor +- Cercano pero profesional +- Uso moderado de emojis +- Hashtags relevantes (máximo 3-5) + +REGLAS: +- Nunca uses lenguaje ofensivo +- No hagas promesas exageradas +- Sé honesto y transparente +- Enfócate en ayudar, no en vender directamente +- Adapta el contenido a cada plataforma""" + + async def generate_tip_tech( + self, + category: str, + platform: str, + template: Optional[str] = None + ) -> str: + """Generar un tip tech.""" + char_limits = { + "x": 280, + "threads": 500, + "instagram": 2200, + "facebook": 500 + } + + prompt = f"""Genera un tip de tecnología para la categoría: {category} + +PLATAFORMA: {platform} +LÍMITE DE CARACTERES: {char_limits.get(platform, 500)} + +{f'USA ESTE TEMPLATE COMO BASE: {template}' if template else ''} + +REQUISITOS: +- Tip práctico y accionable +- Fácil de entender +- Incluye un emoji relevante al inicio +- Termina con 2-3 hashtags relevantes +- NO incluyas enlaces + +Responde SOLO con el texto del post, sin explicaciones.""" + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self._get_system_prompt()}, + {"role": "user", "content": prompt} + ], + max_tokens=300, + temperature=0.7 + ) + + return response.choices[0].message.content.strip() + + async def generate_product_post( + self, + product: Dict, + platform: str + ) -> str: + """Generar post para un producto.""" + char_limits = { + "x": 280, + "threads": 500, + "instagram": 2200, + "facebook": 1000 + } + + prompt = f"""Genera un post promocional para este producto: + +PRODUCTO: {product['name']} +DESCRIPCIÓN: {product.get('description', 'N/A')} +PRECIO: ${product['price']:,.2f} MXN +CATEGORÍA: {product['category']} +ESPECIFICACIONES: {json.dumps(product.get('specs', {}), ensure_ascii=False)} +PUNTOS DESTACADOS: {', '.join(product.get('highlights', []))} + +PLATAFORMA: {platform} +LÍMITE DE CARACTERES: {char_limits.get(platform, 500)} + +REQUISITOS: +- Destaca los beneficios principales +- Incluye el precio +- Usa emojis relevantes +- Incluye CTA sutil (ej: "Contáctanos", "Más info en DM") +- Termina con 2-3 hashtags +- NO inventes especificaciones + +Responde SOLO con el texto del post.""" + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self._get_system_prompt()}, + {"role": "user", "content": prompt} + ], + max_tokens=400, + temperature=0.7 + ) + + return response.choices[0].message.content.strip() + + async def generate_service_post( + self, + service: Dict, + platform: str + ) -> str: + """Generar post para un servicio.""" + char_limits = { + "x": 280, + "threads": 500, + "instagram": 2200, + "facebook": 1000 + } + + prompt = f"""Genera un post promocional para este servicio: + +SERVICIO: {service['name']} +DESCRIPCIÓN: {service.get('description', 'N/A')} +CATEGORÍA: {service['category']} +SECTORES OBJETIVO: {', '.join(service.get('target_sectors', []))} +BENEFICIOS: {', '.join(service.get('benefits', []))} +CTA: {service.get('call_to_action', 'Contáctanos para más información')} + +PLATAFORMA: {platform} +LÍMITE DE CARACTERES: {char_limits.get(platform, 500)} + +REQUISITOS: +- Enfócate en el problema que resuelve +- Destaca 2-3 beneficios clave +- Usa emojis relevantes (✅, 🚀, 💡) +- Incluye el CTA +- Termina con 2-3 hashtags +- Tono consultivo, no vendedor + +Responde SOLO con el texto del post.""" + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self._get_system_prompt()}, + {"role": "user", "content": prompt} + ], + max_tokens=400, + temperature=0.7 + ) + + return response.choices[0].message.content.strip() + + async def generate_thread( + self, + topic: str, + num_posts: int = 5 + ) -> List[str]: + """Generar un hilo educativo.""" + prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic} + +REQUISITOS: +- Post 1: Gancho que capture atención +- Posts 2-{num_posts-1}: Contenido educativo de valor +- Post {num_posts}: Conclusión con CTA + +FORMATO: +- Cada post máximo 280 caracteres +- Numera cada post (1/, 2/, etc.) +- Usa emojis relevantes +- El último post incluye hashtags + +Responde con cada post en una línea separada, sin explicaciones.""" + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self._get_system_prompt()}, + {"role": "user", "content": prompt} + ], + max_tokens=1500, + temperature=0.7 + ) + + content = response.choices[0].message.content.strip() + # Separar posts por líneas no vacías + posts = [p.strip() for p in content.split('\n') if p.strip()] + + return posts + + async def generate_response_suggestion( + self, + interaction_content: str, + interaction_type: str, + context: Optional[str] = None + ) -> List[str]: + """Generar sugerencias de respuesta para una interacción.""" + prompt = f"""Un usuario escribió esto en redes sociales: + +"{interaction_content}" + +TIPO DE INTERACCIÓN: {interaction_type} +{f'CONTEXTO ADICIONAL: {context}' if context else ''} + +Genera 3 opciones de respuesta diferentes: +1. Respuesta corta y amigable +2. Respuesta que invite a continuar la conversación +3. Respuesta que dirija a más información/contacto + +REQUISITOS: +- Máximo 280 caracteres cada una +- Tono amigable y profesional +- Si es una queja, sé empático +- Si es una pregunta técnica, sé útil + +Responde con las 3 opciones numeradas, una por línea.""" + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self._get_system_prompt()}, + {"role": "user", "content": prompt} + ], + max_tokens=500, + temperature=0.8 + ) + + content = response.choices[0].message.content.strip() + suggestions = [s.strip() for s in content.split('\n') if s.strip()] + + # Limpiar numeración si existe + cleaned = [] + for s in suggestions: + if s[0].isdigit() and (s[1] == '.' or s[1] == ')'): + s = s[2:].strip() + cleaned.append(s) + + return cleaned[:3] # Máximo 3 sugerencias + + async def adapt_content_for_platform( + self, + content: str, + target_platform: str + ) -> str: + """Adaptar contenido existente a una plataforma específica.""" + char_limits = { + "x": 280, + "threads": 500, + "instagram": 2200, + "facebook": 1000 + } + + prompt = f"""Adapta este contenido para {target_platform}: + +CONTENIDO ORIGINAL: +{content} + +LÍMITE DE CARACTERES: {char_limits.get(target_platform, 500)} + +REQUISITOS PARA {target_platform.upper()}: +{"- Muy conciso, directo al punto" if target_platform == "x" else ""} +{"- Puede ser más extenso, incluir más contexto" if target_platform == "instagram" else ""} +{"- Tono más casual y cercano" if target_platform == "threads" else ""} +{"- Puede incluir links, más profesional" if target_platform == "facebook" else ""} +- Mantén la esencia del mensaje +- Ajusta hashtags según la plataforma + +Responde SOLO con el contenido adaptado.""" + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self._get_system_prompt()}, + {"role": "user", "content": prompt} + ], + max_tokens=400, + temperature=0.6 + ) + + return response.choices[0].message.content.strip() + + +# Instancia global +content_generator = ContentGenerator() diff --git a/app/services/image_generator.py b/app/services/image_generator.py new file mode 100644 index 0000000..e92676b --- /dev/null +++ b/app/services/image_generator.py @@ -0,0 +1,174 @@ +""" +Servicio de generación de imágenes para posts. +""" + +import os +from typing import Dict, Optional +from pathlib import Path +from html2image import Html2Image +from PIL import Image +from jinja2 import Environment, FileSystemLoader + +from app.core.config import settings + + +class ImageGenerator: + """Generador de imágenes usando plantillas HTML.""" + + def __init__(self): + self.templates_dir = Path("templates") + self.output_dir = Path("uploads/generated") + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.jinja_env = Environment( + loader=FileSystemLoader(self.templates_dir) + ) + + self.hti = Html2Image( + output_path=str(self.output_dir), + custom_flags=['--no-sandbox', '--disable-gpu'] + ) + + def _render_template(self, template_name: str, variables: Dict) -> str: + """Renderizar una plantilla HTML con variables.""" + template = self.jinja_env.get_template(template_name) + return template.render(**variables) + + async def generate_tip_card( + self, + title: str, + content: str, + category: str, + output_name: str + ) -> str: + """Generar imagen de tip tech.""" + variables = { + "title": title, + "content": content, + "category": category, + "logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png", + "website": settings.BUSINESS_WEBSITE, + "business_name": settings.BUSINESS_NAME + } + + html_content = self._render_template("tip_card.html", variables) + + # Generar imagen + output_file = f"{output_name}.png" + self.hti.screenshot( + html_str=html_content, + save_as=output_file, + size=(1080, 1080) + ) + + return str(self.output_dir / output_file) + + async def generate_product_card( + self, + name: str, + price: float, + image_url: str, + highlights: list, + output_name: str + ) -> str: + """Generar imagen de producto.""" + variables = { + "name": name, + "price": f"${price:,.2f} MXN", + "image_url": image_url, + "highlights": highlights[:3], # Máximo 3 highlights + "logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png", + "website": settings.BUSINESS_WEBSITE, + "business_name": settings.BUSINESS_NAME + } + + html_content = self._render_template("product_card.html", variables) + + output_file = f"{output_name}.png" + self.hti.screenshot( + html_str=html_content, + save_as=output_file, + size=(1080, 1080) + ) + + return str(self.output_dir / output_file) + + async def generate_service_card( + self, + name: str, + tagline: str, + benefits: list, + icon: str, + output_name: str + ) -> str: + """Generar imagen de servicio.""" + variables = { + "name": name, + "tagline": tagline, + "benefits": benefits[:4], # Máximo 4 beneficios + "icon": icon, + "logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png", + "website": settings.BUSINESS_WEBSITE, + "business_name": settings.BUSINESS_NAME + } + + html_content = self._render_template("service_card.html", variables) + + output_file = f"{output_name}.png" + self.hti.screenshot( + html_str=html_content, + save_as=output_file, + size=(1080, 1080) + ) + + return str(self.output_dir / output_file) + + async def resize_for_platform( + self, + image_path: str, + platform: str + ) -> str: + """Redimensionar imagen para una plataforma específica.""" + sizes = { + "x": (1200, 675), # 16:9 + "threads": (1080, 1080), # 1:1 + "instagram": (1080, 1080), # 1:1 + "facebook": (1200, 630) # ~1.9:1 + } + + target_size = sizes.get(platform, (1080, 1080)) + + img = Image.open(image_path) + + # Crear nueva imagen con el tamaño objetivo + new_img = Image.new('RGB', target_size, (26, 26, 46)) # Color de fondo de la marca + + # Calcular posición para centrar + img_ratio = img.width / img.height + target_ratio = target_size[0] / target_size[1] + + if img_ratio > target_ratio: + # Imagen más ancha + new_width = target_size[0] + new_height = int(new_width / img_ratio) + else: + # Imagen más alta + new_height = target_size[1] + new_width = int(new_height * img_ratio) + + img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Centrar en la nueva imagen + x = (target_size[0] - new_width) // 2 + y = (target_size[1] - new_height) // 2 + new_img.paste(img_resized, (x, y)) + + # Guardar + output_path = image_path.replace('.png', f'_{platform}.png') + new_img.save(output_path, 'PNG', quality=95) + + return output_path + + +# Instancia global +image_generator = ImageGenerator() diff --git a/dashboard/templates/index.html b/dashboard/templates/index.html new file mode 100644 index 0000000..5e4c582 --- /dev/null +++ b/dashboard/templates/index.html @@ -0,0 +1,181 @@ + + + + + + Dashboard - Social Media Automation + + + + + +
+
+

+ Consultoría AS - Social Media +

+ +
+
+ +
+ +
+
+
{{ stats.posts_today }}
+
Posts Hoy
+
+
+
{{ stats.posts_week }}
+
Posts Semana
+
+
+
{{ stats.pending_approval }}
+
Pendientes
+
+
+
{{ stats.scheduled }}
+
Programados
+
+
+
{{ stats.interactions_pending }}
+
Interacciones
+
+
+ +
+ +
+

Pendientes de Aprobación

+ {% if pending_posts %} + {% for post in pending_posts %} +
+
+ + {{ post.content_type }} + + + {{ post.scheduled_at }} + +
+

{{ post.content[:200] }}...

+
+ + + +
+
+ {% endfor %} + {% else %} +

No hay posts pendientes

+ {% endif %} +
+ + +
+

Próximas Publicaciones

+ {% if scheduled_posts %} + {% for post in scheduled_posts %} +
+
+
{{ post.scheduled_at }}
+
+
+ + {{ post.content_type }} + +

+ {{ post.content[:100] }}... +

+
+
+ {% for platform in post.platforms %} + + {{ platform }} + + {% endfor %} +
+
+ {% endfor %} + {% else %} +

No hay posts programados

+ {% endif %} +
+
+ + +
+

Interacciones Recientes

+ {% if recent_interactions %} +
+ {% for interaction in recent_interactions %} +
+
+
+ @{{ interaction.author_username }} + {{ interaction.platform }} +
+ {{ interaction.interaction_at }} +
+

{{ interaction.content }}

+
+ + +
+
+ {% endfor %} +
+ {% else %} +

No hay interacciones pendientes

+ {% endif %} +
+
+ + + + diff --git a/data/tips_iniciales.json b/data/tips_iniciales.json new file mode 100644 index 0000000..2f3ceb9 --- /dev/null +++ b/data/tips_iniciales.json @@ -0,0 +1,122 @@ +[ + { + "category": "productividad", + "title": "Atajos de teclado Windows", + "template": "Win + V activa el historial del portapapeles en Windows. Puedes pegar cualquier cosa que hayas copiado antes.", + "difficulty": "basico" + }, + { + "category": "seguridad", + "title": "Contraseñas seguras", + "template": "Una contraseña segura tiene mínimo 12 caracteres, mayúsculas, minúsculas, números y símbolos. Mejor aún: usa un gestor de contraseñas.", + "difficulty": "basico" + }, + { + "category": "hardware", + "title": "Limpiar tu PC", + "template": "Limpia el polvo de tu PC cada 3-6 meses. El polvo acumulado aumenta la temperatura y reduce el rendimiento.", + "difficulty": "basico" + }, + { + "category": "software", + "title": "Actualizaciones", + "template": "Mantén tu sistema operativo actualizado. Las actualizaciones no solo traen funciones nuevas, también parches de seguridad críticos.", + "difficulty": "basico" + }, + { + "category": "productividad", + "title": "Múltiples escritorios", + "template": "Win + Tab te permite crear múltiples escritorios virtuales. Perfecto para separar trabajo personal de proyectos.", + "difficulty": "intermedio" + }, + { + "category": "redes", + "title": "Velocidad de internet", + "template": "¿Internet lento? Prueba cambiar el DNS a 1.1.1.1 (Cloudflare) o 8.8.8.8 (Google). Puede mejorar la velocidad de navegación.", + "difficulty": "intermedio" + }, + { + "category": "seguridad", + "title": "Autenticación 2FA", + "template": "Activa la autenticación de dos factores (2FA) en todas tus cuentas importantes. Es la mejor defensa contra hackeos.", + "difficulty": "basico" + }, + { + "category": "ia", + "title": "Prompts efectivos", + "template": "Para mejores resultados con IA, sé específico en tus prompts. En lugar de 'escribe un email', prueba 'escribe un email profesional de 3 párrafos solicitando una reunión'.", + "difficulty": "intermedio" + }, + { + "category": "hardware", + "title": "SSD vs HDD", + "template": "Un SSD puede hacer que tu PC arranque en 10-15 segundos vs 1-2 minutos con HDD. Es la mejor inversión para PCs antiguas.", + "difficulty": "basico" + }, + { + "category": "productividad", + "title": "Captura de pantalla", + "template": "Win + Shift + S abre la herramienta de recorte. Puedes capturar una región, ventana o pantalla completa al instante.", + "difficulty": "basico" + }, + { + "category": "software", + "title": "Programas de inicio", + "template": "¿PC lenta al encender? Desactiva programas innecesarios en el inicio. Ctrl + Shift + Esc > Inicio > Desactivar los que no necesites.", + "difficulty": "basico" + }, + { + "category": "seguridad", + "title": "Phishing", + "template": "Antes de hacer clic en un enlace, pasa el mouse encima para ver la URL real. Si no coincide con el sitio esperado, es phishing.", + "difficulty": "basico" + }, + { + "category": "ia", + "title": "ChatGPT para código", + "template": "ChatGPT puede explicar código línea por línea. Copia el código y pide: 'Explica este código como si tuviera 5 años de experiencia'.", + "difficulty": "intermedio" + }, + { + "category": "hardware", + "title": "RAM suficiente", + "template": "En 2024, 8GB de RAM es el mínimo. Para trabajo con múltiples apps o desarrollo, 16GB es lo recomendado. Para edición de video, 32GB.", + "difficulty": "intermedio" + }, + { + "category": "redes", + "title": "WiFi 5GHz", + "template": "Si tu router tiene banda 5GHz, úsala para dispositivos cercanos. Es más rápida que 2.4GHz, aunque tiene menor alcance.", + "difficulty": "basico" + }, + { + "category": "productividad", + "title": "Notion para notas", + "template": "Notion es gratis para uso personal. Puedes organizar notas, tareas y proyectos en un solo lugar con templates listos para usar.", + "difficulty": "basico" + }, + { + "category": "seguridad", + "title": "Backup 3-2-1", + "template": "Regla 3-2-1 de backups: 3 copias de tus datos, 2 en medios diferentes, 1 fuera de tu ubicación (nube). Así nunca pierdes nada.", + "difficulty": "intermedio" + }, + { + "category": "software", + "title": "Navegador ligero", + "template": "¿Chrome consume mucha RAM? Prueba Edge (sí, el de Microsoft) o Brave. Usan la misma base pero optimizan mejor los recursos.", + "difficulty": "basico" + }, + { + "category": "ia", + "title": "IA para Excel", + "template": "ChatGPT puede crear fórmulas de Excel. Describe lo que necesitas en español y pide la fórmula. Ahorra horas de búsqueda.", + "difficulty": "basico" + }, + { + "category": "hardware", + "title": "Monitor externo", + "template": "Un segundo monitor aumenta la productividad hasta 42% según estudios. No necesita ser caro, cualquier monitor extra ayuda.", + "difficulty": "basico" + } +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2cb4a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,182 @@ +version: '3.8' + +services: + # =========================================== + # APLICACIÓN PRINCIPAL (FastAPI) + # =========================================== + app: + build: + context: . + dockerfile: Dockerfile + container_name: social-automation-app + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation + - REDIS_URL=redis://redis:6379/0 + env_file: + - .env + volumes: + - ./app:/app/app + - ./templates:/app/templates + - ./data:/app/data + - uploaded_images:/app/uploads + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + networks: + - social-network + + # =========================================== + # CELERY WORKER (Procesamiento de tareas) + # =========================================== + worker: + build: + context: . + dockerfile: Dockerfile + container_name: social-automation-worker + command: celery -A worker.celery_app worker --loglevel=info --concurrency=2 + environment: + - DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation + - REDIS_URL=redis://redis:6379/0 + env_file: + - .env + volumes: + - ./app:/app/app + - ./worker:/app/worker + - ./templates:/app/templates + - uploaded_images:/app/uploads + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + networks: + - social-network + + # =========================================== + # CELERY BEAT (Programador de tareas) + # =========================================== + beat: + build: + context: . + dockerfile: Dockerfile + container_name: social-automation-beat + command: celery -A worker.celery_app beat --loglevel=info + environment: + - DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation + - REDIS_URL=redis://redis:6379/0 + env_file: + - .env + volumes: + - ./worker:/app/worker + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + networks: + - social-network + + # =========================================== + # FLOWER (Monitor de Celery) + # =========================================== + flower: + build: + context: . + dockerfile: Dockerfile + container_name: social-automation-flower + command: celery -A worker.celery_app flower --port=5555 + ports: + - "5555:5555" + environment: + - REDIS_URL=redis://redis:6379/0 + env_file: + - .env + depends_on: + - redis + - worker + restart: unless-stopped + networks: + - social-network + + # =========================================== + # POSTGRESQL (Base de datos) + # =========================================== + db: + image: postgres:15-alpine + container_name: social-automation-db + environment: + - POSTGRES_USER=social_user + - POSTGRES_PASSWORD=social_pass + - POSTGRES_DB=social_automation + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U social_user -d social_automation"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - social-network + + # =========================================== + # REDIS (Cola de mensajes) + # =========================================== + redis: + image: redis:7-alpine + container_name: social-automation-redis + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + restart: unless-stopped + networks: + - social-network + + # =========================================== + # NGINX (Reverse Proxy) + # =========================================== + nginx: + image: nginx:alpine + container_name: social-automation-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + - ./dashboard/static:/usr/share/nginx/html/static + depends_on: + - app + restart: unless-stopped + networks: + - social-network + +# =========================================== +# VOLÚMENES +# =========================================== +volumes: + postgres_data: + driver: local + redis_data: + driver: local + uploaded_images: + driver: local + +# =========================================== +# REDES +# =========================================== +networks: + social-network: + driver: bridge diff --git a/docs/plans/2025-01-28-social-media-automation-design.md b/docs/plans/2025-01-28-social-media-automation-design.md new file mode 100644 index 0000000..854067d --- /dev/null +++ b/docs/plans/2025-01-28-social-media-automation-design.md @@ -0,0 +1,231 @@ +# Sistema de Automatización de Redes Sociales - Consultoría AS + +## Resumen Ejecutivo + +Sistema automatizado para la creación y publicación de contenido en redes sociales (X, Threads, Instagram, Facebook) para Consultoría AS, empresa de tecnología en Tijuana especializada en soluciones de IA, automatización y venta de equipos de cómputo e impresión 3D. + +## Objetivos + +- Automatizar la generación de contenido educativo (tips tech, datos curiosos) +- Promocionar productos del catálogo (equipos de cómputo, impresoras 3D) +- Destacar servicios de consultoría (IA, chatbots, sistemas a medida) +- Mantener presencia constante en redes con contenido de valor +- Gestionar interacciones con la audiencia desde un dashboard centralizado + +--- + +## Arquitectura General + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SOCIAL MEDIA AUTOMATION │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ FUENTES │───▶│ GENERADOR │───▶│ PUBLICADOR │ │ +│ │ DE DATOS │ │ DE CONTENIDO│ │ MULTI-PLAT │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +│ │ │ │ │ +│ • Catálogo productos │ DeepSeek API │ • X API │ +│ • Tips tech (DB) │ │ • Threads │ +│ • Perfiles inspiración │ ┌──────────────┐ │ • Instagram │ +│ • Input manual └▶│ GENERADOR │ │ • Facebook │ +│ │ DE IMÁGENES │ │ │ +│ └──────────────┘ │ │ +│ │ │ +│ ┌──────────────────────────────────────────────┘ │ +│ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ │ +│ └─▶│ SCHEDULER │ │ DASHBOARD │◀── Revisión manual │ +│ │ (Celery) │ │ (FastAPI) │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Stack Tecnológico + +| Componente | Tecnología | Justificación | +|------------|------------|---------------| +| **Backend** | Python + FastAPI | Async, rápido, ideal para IA | +| **Base de datos** | PostgreSQL | Robusto, JSON support | +| **Cola de tareas** | Celery + Redis | Tareas programadas confiables | +| **ORM** | SQLAlchemy | Maduro, bien documentado | +| **Imágenes** | Pillow + html2image | Flexible, sin dependencias externas | +| **IA** | DeepSeek API | Económico (~$0.27/M tokens), buena calidad | +| **Contenedores** | Docker Compose | Despliegue reproducible | + +--- + +## Plataformas y Frecuencia + +| Plataforma | Frecuencia | Posts/Mes | Límite API | +|------------|------------|-----------|------------| +| **X (Twitter)** | Alta (1-2/día) | ~80-100 | 1,500/mes (Free) ✅ | +| **Threads** | Alta (1-2/día) | ~80 | 250/día ✅ | +| **Instagram** | Alta (1-2/día) | ~66 | 25/día ✅ | +| **Facebook** | Media (3-5/sem) | ~36 | Sin límite práctico ✅ | + +**Total aproximado:** ~280-300 posts/mes + +--- + +## Tipos de Contenido + +### Automático (sin revisión) +| Tipo | Frecuencia | Plataformas | +|------|------------|-------------| +| Tips tech | Diario | X, Threads, IG | +| Datos curiosos | Diario | X, Threads | +| Frases motivacionales | 3x semana | IG, FB | +| Efemérides tech | Variable | X, Threads | + +### Semi-automático (revisión rápida) +| Tipo | Frecuencia | Plataformas | +|------|------------|-------------| +| Productos del catálogo | 3x semana | FB, IG | +| Servicios destacados | 2x semana | FB, IG, X | +| Hilos educativos | 1x semana | X, Threads | + +### Manual (contenido importante) +| Tipo | Frecuencia | Plataformas | +|------|------------|-------------| +| Casos de éxito | Mensual | Todas | +| Promociones especiales | Variable | Todas | +| Anuncios importantes | Variable | Todas | + +--- + +## Estructura de Base de Datos + +### Tablas Principales + +```sql +-- Catálogo de productos +products ( + id, name, description, price, category, + specs JSON, images[], stock, created_at +) + +-- Servicios de consultoría +services ( + id, name, description, target_sector, + benefits[], case_studies[], created_at +) + +-- Banco de tips +tip_templates ( + id, category, template, variables, + used_count, last_used, created_at +) + +-- Posts generados +posts ( + id, content, content_type, platforms[], + status, scheduled_at, published_at, + image_url, approval_needed, platform_ids JSON +) + +-- Calendario de contenido +content_calendar ( + id, day_of_week, time, platform, + content_type, is_active +) + +-- Plantillas de imágenes +image_templates ( + id, name, template_file, variables[], category +) + +-- Interacciones +interactions ( + id, platform, type, post_id, external_id, + author_username, author_name, content, + responded, response_content, responded_at, + is_lead, created_at +) +``` + +--- + +## Flujo de Generación de Contenido + +1. **Scheduler** revisa calendario cada hora +2. **Selecciona fuente** según tipo (tips, productos, servicios) +3. **Genera contenido** con DeepSeek API usando prompts especializados +4. **Adapta por plataforma** (caracteres, hashtags, estilo) +5. **Genera imagen** si aplica (plantilla o foto de catálogo) +6. **Decide flujo**: auto-programar o enviar a aprobación + +--- + +## Dashboard de Gestión + +### Funcionalidades + +- **Home**: Resumen, métricas, alertas +- **Posts**: Historial, filtros, estados +- **Productos**: CRUD del catálogo +- **Calendar**: Vista visual, reprogramación drag-and-drop +- **Queue**: Cola de publicación, pausar/reanudar +- **Interacciones**: Responder comentarios, DMs, menciones + +### Gestión de Interacciones + +- Ver todas las interacciones en un solo lugar +- Respuestas sugeridas por IA +- Marcar leads potenciales +- Filtrar por plataforma, tipo, estado + +--- + +## Arquitectura de Despliegue + +```yaml +# Docker Compose - Servicios +services: + - app (FastAPI :8000) + - worker (Celery Worker) + - beat (Celery Beat Scheduler) + - flower (Monitor :5555) + - db (PostgreSQL :5432) + - redis (Redis :6379) + - nginx (Reverse Proxy :80/:443) +``` + +--- + +## APIs Externas Requeridas + +| API | Propósito | Costo | Estado | +|-----|-----------|-------|--------| +| DeepSeek | Generación de contenido | ~$0.27/M tokens | Pendiente | +| X API v2 | Publicar en Twitter | Free tier | Pendiente | +| Meta Graph API | FB, IG, Threads | Gratis | Pendiente | + +--- + +## Estimación de Costos Mensuales + +| Concepto | Costo | +|----------|-------| +| DeepSeek API (~300 posts) | ~$5-10 | +| X API (Free tier) | $0 | +| Meta API | $0 | +| Servidor (ya existente) | $0 | +| **Total** | **~$5-10/mes** | + +--- + +## Referencias de Estilo + +Perfiles de inspiración para el tono y tipo de contenido: +- @midudev - Tips desarrollo web, hilos educativos +- @MoureDev - Python, retos, comunidad +- @SoyDalto - Contenido accesible, motivacional +- @SuGE3K - Tips tech prácticos +- @FrankSanabria - Desarrollo y emprendimiento + +**Tono Consultoría AS:** Profesional pero cercano, educativo, orientado a soluciones. diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..3a7dac5 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,92 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logs + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Upstream para FastAPI + upstream app { + server app:8000; + } + + # Upstream para Flower (monitor de Celery) + upstream flower { + server flower:5555; + } + + server { + listen 80; + server_name localhost; + + # Redirigir a HTTPS en producción + # return 301 https://$server_name$request_uri; + + location / { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # API + location /api { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Archivos estáticos + location /static { + alias /usr/share/nginx/html/static; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Flower (monitor de Celery) - proteger en producción + location /flower/ { + proxy_pass http://flower/; + proxy_set_header Host $host; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Health check + location /health { + proxy_pass http://app/api/health; + } + } + + # HTTPS server (descomentar en producción) + # server { + # listen 443 ssl http2; + # server_name localhost; + # + # ssl_certificate /etc/nginx/ssl/cert.pem; + # ssl_certificate_key /etc/nginx/ssl/key.pem; + # + # ssl_protocols TLSv1.2 TLSv1.3; + # ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + # ssl_prefer_server_ciphers off; + # + # # ... mismas locations que HTTP ... + # } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9b4e3d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,48 @@ +# FastAPI y servidor +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 + +# Base de datos +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +alembic==1.13.1 + +# Celery y Redis +celery==5.3.6 +redis==5.0.1 +flower==2.0.1 + +# HTTP Client +httpx==0.26.0 +aiohttp==3.9.1 + +# APIs de Redes Sociales +tweepy==4.14.0 # X/Twitter + +# IA +openai==1.10.0 # Compatible con DeepSeek API + +# Imágenes +Pillow==10.2.0 +html2image==2.0.4.3 + +# Utilidades +python-dotenv==1.0.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 + +# Templates HTML +jinja2==3.1.3 + +# Testing +pytest==7.4.4 +pytest-asyncio==0.23.3 +pytest-cov==4.1.0 + +# Desarrollo +black==24.1.1 +isort==5.13.2 +flake8==7.0.0 diff --git a/scripts/init_db.sql b/scripts/init_db.sql new file mode 100644 index 0000000..af93d04 --- /dev/null +++ b/scripts/init_db.sql @@ -0,0 +1,24 @@ +-- Inicialización de la base de datos +-- Este script se ejecuta automáticamente cuando se crea el contenedor de PostgreSQL + +-- Crear extensiones necesarias +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Crear usuario si no existe (ya lo hace Docker, pero por si acaso) +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'social_user') THEN + CREATE ROLE social_user WITH LOGIN PASSWORD 'social_pass'; + END IF; +END +$$; + +-- Dar permisos +GRANT ALL PRIVILEGES ON DATABASE social_automation TO social_user; + +-- Mensaje de confirmación +DO $$ +BEGIN + RAISE NOTICE 'Base de datos inicializada correctamente'; +END +$$; diff --git a/scripts/seed_database.py b/scripts/seed_database.py new file mode 100644 index 0000000..914cfca --- /dev/null +++ b/scripts/seed_database.py @@ -0,0 +1,180 @@ +""" +Script para poblar la base de datos con datos iniciales. +""" + +import json +import sys +from pathlib import Path +from datetime import time + +# Agregar el directorio raíz al path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.core.database import SessionLocal, engine +from app.models import Base, TipTemplate, ContentCalendar, Service + + +def seed_tips(): + """Cargar tips iniciales.""" + db = SessionLocal() + + try: + # Cargar JSON de tips + tips_file = Path(__file__).parent.parent / "data" / "tips_iniciales.json" + with open(tips_file, "r", encoding="utf-8") as f: + tips_data = json.load(f) + + for tip in tips_data: + existing = db.query(TipTemplate).filter( + TipTemplate.title == tip["title"] + ).first() + + if not existing: + new_tip = TipTemplate( + category=tip["category"], + title=tip["title"], + template=tip["template"], + difficulty=tip.get("difficulty", "basico"), + is_active=True, + is_evergreen=True + ) + db.add(new_tip) + + db.commit() + print(f"✅ {len(tips_data)} tips cargados") + + finally: + db.close() + + +def seed_calendar(): + """Crear calendario inicial.""" + db = SessionLocal() + + try: + # Calendario de ejemplo + calendar_entries = [ + # Tips diarios - mañana + {"day": 0, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]}, + {"day": 1, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]}, + {"day": 2, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]}, + {"day": 3, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]}, + {"day": 4, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]}, + + # Tips diarios - tarde + {"day": 0, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]}, + {"day": 1, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]}, + {"day": 2, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]}, + {"day": 3, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]}, + {"day": 4, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]}, + + # Productos - Lunes, Miércoles, Viernes + {"day": 0, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True}, + {"day": 2, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True}, + {"day": 4, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True}, + + # Servicios - Martes, Jueves + {"day": 1, "time": "11:00", "type": "servicio", "platforms": ["x", "facebook", "instagram"], "approval": True}, + {"day": 3, "time": "11:00", "type": "servicio", "platforms": ["x", "facebook", "instagram"], "approval": True}, + + # Frases motivacionales - Lunes, Miércoles, Viernes + {"day": 0, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]}, + {"day": 2, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]}, + {"day": 4, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]}, + ] + + for entry in calendar_entries: + hour, minute = map(int, entry["time"].split(":")) + + new_entry = ContentCalendar( + day_of_week=entry["day"], + time=time(hour=hour, minute=minute), + content_type=entry["type"], + platforms=entry["platforms"], + requires_approval=entry.get("approval", False), + is_active=True + ) + db.add(new_entry) + + db.commit() + print(f"✅ {len(calendar_entries)} entradas de calendario creadas") + + finally: + db.close() + + +def seed_services(): + """Crear servicios iniciales.""" + db = SessionLocal() + + try: + services = [ + { + "name": "Chatbots Inteligentes", + "description": "Implementamos chatbots con IA para WhatsApp e Instagram que atienden a tus clientes 24/7.", + "short_description": "Chatbots IA para WhatsApp e Instagram", + "category": "automatizacion", + "target_sectors": ["retail", "servicios", "hoteles"], + "benefits": ["Atención 24/7", "Respuestas instantáneas", "Integración con CRM", "Escalamiento a humanos"], + "call_to_action": "Agenda una demo gratuita" + }, + { + "name": "Sistemas para Hoteles", + "description": "Software completo para gestión hotelera: reservas, nómina, inventario y dashboards financieros.", + "short_description": "Gestión hotelera integral", + "category": "desarrollo", + "target_sectors": ["hoteles", "turismo"], + "benefits": ["Control de reservas", "Gestión de nómina", "Inventario automatizado", "Reportes en tiempo real"], + "call_to_action": "Solicita una cotización" + }, + { + "name": "Automatización de Procesos", + "description": "Automatizamos tareas repetitivas con bots y flujos de trabajo inteligentes.", + "short_description": "Automatiza tareas repetitivas", + "category": "automatizacion", + "target_sectors": ["empresas", "pymes", "corporativos"], + "benefits": ["Ahorro de tiempo", "Reducción de errores", "Mayor productividad", "ROI medible"], + "call_to_action": "Cuéntanos tu proceso" + }, + { + "name": "Integración de IA", + "description": "Integramos inteligencia artificial en tus procesos de negocio para tomar mejores decisiones.", + "short_description": "IA para tu negocio", + "category": "ia", + "target_sectors": ["empresas", "pymes", "corporativos"], + "benefits": ["Análisis predictivo", "Automatización inteligente", "Insights de datos", "Ventaja competitiva"], + "call_to_action": "Descubre cómo la IA puede ayudarte" + } + ] + + for service in services: + existing = db.query(Service).filter(Service.name == service["name"]).first() + + if not existing: + new_service = Service(**service, is_active=True) + db.add(new_service) + + db.commit() + print(f"✅ {len(services)} servicios creados") + + finally: + db.close() + + +def main(): + """Ejecutar todos los seeds.""" + print("🌱 Iniciando seed de base de datos...\n") + + # Crear tablas + Base.metadata.create_all(bind=engine) + print("✅ Tablas creadas\n") + + seed_tips() + seed_calendar() + seed_services() + + print("\n✅ Seed completado!") + + +if __name__ == "__main__": + main() diff --git a/templates/tip_card.html b/templates/tip_card.html new file mode 100644 index 0000000..b7be2c4 --- /dev/null +++ b/templates/tip_card.html @@ -0,0 +1,127 @@ + + + + + + +
+
+ +
💡 {{ category }}
+ +

{{ title }}

+ +

{{ content }}

+ + + + diff --git a/worker/__init__.py b/worker/__init__.py new file mode 100644 index 0000000..e416454 --- /dev/null +++ b/worker/__init__.py @@ -0,0 +1 @@ +# Celery Worker diff --git a/worker/celery_app.py b/worker/celery_app.py new file mode 100644 index 0000000..65d28ef --- /dev/null +++ b/worker/celery_app.py @@ -0,0 +1,61 @@ +""" +Configuración de Celery para tareas en background. +""" + +from celery import Celery +from celery.schedules import crontab + +from app.core.config import settings + +# Crear aplicación Celery +celery_app = Celery( + "social_automation", + broker=settings.REDIS_URL, + backend=settings.REDIS_URL, + include=[ + "worker.tasks.generate_content", + "worker.tasks.publish_post", + "worker.tasks.fetch_interactions", + "worker.tasks.cleanup" + ] +) + +# Configuración +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="America/Tijuana", + enable_utc=True, + task_track_started=True, + task_time_limit=300, # 5 minutos máximo por tarea + worker_prefetch_multiplier=1, + worker_concurrency=2 +) + +# Programación de tareas periódicas +celery_app.conf.beat_schedule = { + # Generar contenido cada hora + "generate-scheduled-content": { + "task": "worker.tasks.generate_content.generate_scheduled_content", + "schedule": crontab(minute=0), # Cada hora en punto + }, + + # Publicar posts programados cada minuto + "publish-scheduled-posts": { + "task": "worker.tasks.publish_post.publish_scheduled_posts", + "schedule": crontab(minute="*"), # Cada minuto + }, + + # Obtener interacciones cada 5 minutos + "fetch-interactions": { + "task": "worker.tasks.fetch_interactions.fetch_all_interactions", + "schedule": crontab(minute="*/5"), # Cada 5 minutos + }, + + # Limpieza diaria a las 3 AM + "daily-cleanup": { + "task": "worker.tasks.cleanup.daily_cleanup", + "schedule": crontab(hour=3, minute=0), + }, +} diff --git a/worker/tasks/__init__.py b/worker/tasks/__init__.py new file mode 100644 index 0000000..1c8b80e --- /dev/null +++ b/worker/tasks/__init__.py @@ -0,0 +1 @@ +# Celery Tasks diff --git a/worker/tasks/cleanup.py b/worker/tasks/cleanup.py new file mode 100644 index 0000000..208c1d6 --- /dev/null +++ b/worker/tasks/cleanup.py @@ -0,0 +1,86 @@ +""" +Tareas de limpieza y mantenimiento. +""" + +from datetime import datetime, timedelta + +from worker.celery_app import celery_app +from app.core.database import SessionLocal +from app.models.post import Post +from app.models.interaction import Interaction + + +@celery_app.task(name="worker.tasks.cleanup.daily_cleanup") +def daily_cleanup(): + """ + Limpieza diaria del sistema. + Se ejecuta a las 3 AM. + """ + db = SessionLocal() + + try: + results = [] + + # 1. Eliminar posts fallidos de más de 7 días + cutoff_failed = datetime.utcnow() - timedelta(days=7) + deleted_failed = db.query(Post).filter( + Post.status == "failed", + Post.created_at < cutoff_failed + ).delete() + results.append(f"Posts fallidos eliminados: {deleted_failed}") + + # 2. Archivar interacciones respondidas de más de 30 días + cutoff_interactions = datetime.utcnow() - timedelta(days=30) + archived = db.query(Interaction).filter( + Interaction.responded == True, + Interaction.responded_at < cutoff_interactions, + Interaction.is_archived == False + ).update({"is_archived": True}) + results.append(f"Interacciones archivadas: {archived}") + + # 3. Eliminar posts cancelados de más de 14 días + cutoff_cancelled = datetime.utcnow() - timedelta(days=14) + deleted_cancelled = db.query(Post).filter( + Post.status == "cancelled", + Post.created_at < cutoff_cancelled + ).delete() + results.append(f"Posts cancelados eliminados: {deleted_cancelled}") + + # 4. Resetear contadores de tips (mensualmente, si es día 1) + if datetime.utcnow().day == 1: + from app.models.tip_template import TipTemplate + db.query(TipTemplate).update({"used_count": 0}) + results.append("Contadores de tips reseteados") + + db.commit() + + return results + + except Exception as e: + db.rollback() + return f"Error en limpieza: {e}" + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.cleanup.cleanup_old_images") +def cleanup_old_images(): + """Limpiar imágenes generadas antiguas.""" + import os + from pathlib import Path + + upload_dir = Path("uploads/generated") + if not upload_dir.exists(): + return "Directorio de uploads no existe" + + cutoff = datetime.utcnow() - timedelta(days=7) + deleted = 0 + + for file in upload_dir.glob("*.png"): + file_time = datetime.fromtimestamp(file.stat().st_mtime) + if file_time < cutoff: + file.unlink() + deleted += 1 + + return f"Imágenes eliminadas: {deleted}" diff --git a/worker/tasks/fetch_interactions.py b/worker/tasks/fetch_interactions.py new file mode 100644 index 0000000..858fe07 --- /dev/null +++ b/worker/tasks/fetch_interactions.py @@ -0,0 +1,123 @@ +""" +Tareas para obtener interacciones de redes sociales. +""" + +import asyncio +from datetime import datetime + +from worker.celery_app import celery_app +from app.core.database import SessionLocal +from app.models.interaction import Interaction +from app.models.post import Post +from app.publishers import get_publisher + + +def run_async(coro): + """Helper para ejecutar coroutines en Celery.""" + loop = asyncio.get_event_loop() + return loop.run_until_complete(coro) + + +@celery_app.task(name="worker.tasks.fetch_interactions.fetch_all_interactions") +def fetch_all_interactions(): + """ + Obtener interacciones de todas las plataformas. + Se ejecuta cada 5 minutos. + """ + platforms = ["x", "threads", "instagram", "facebook"] + results = [] + + for platform in platforms: + try: + result = fetch_platform_interactions.delay(platform) + results.append(f"{platform}: tarea enviada") + except Exception as e: + results.append(f"{platform}: error - {e}") + + return results + + +@celery_app.task(name="worker.tasks.fetch_interactions.fetch_platform_interactions") +def fetch_platform_interactions(platform: str): + """Obtener interacciones de una plataforma específica.""" + db = SessionLocal() + + try: + publisher = get_publisher(platform) + + # Obtener menciones + mentions = run_async(publisher.get_mentions()) + new_mentions = 0 + + for mention in mentions: + external_id = mention.get("id") + + # Verificar si ya existe + existing = db.query(Interaction).filter( + Interaction.external_id == external_id + ).first() + + if not existing: + interaction = Interaction( + platform=platform, + interaction_type="mention", + external_id=external_id, + author_username=mention.get("username", mention.get("author_id", "unknown")), + author_name=mention.get("name"), + content=mention.get("text", mention.get("message")), + interaction_at=datetime.fromisoformat( + mention.get("created_at", datetime.utcnow().isoformat()).replace("Z", "+00:00") + ) if mention.get("created_at") else datetime.utcnow() + ) + db.add(interaction) + new_mentions += 1 + + # Obtener comentarios de posts recientes + recent_posts = db.query(Post).filter( + Post.platform_post_ids.isnot(None), + Post.platforms.contains([platform]) + ).order_by(Post.published_at.desc()).limit(10).all() + + new_comments = 0 + + for post in recent_posts: + platform_id = post.platform_post_ids.get(platform) + if not platform_id: + continue + + comments = run_async(publisher.get_comments(platform_id)) + + for comment in comments: + external_id = comment.get("id") + + existing = db.query(Interaction).filter( + Interaction.external_id == external_id + ).first() + + if not existing: + interaction = Interaction( + platform=platform, + interaction_type="comment", + post_id=post.id, + external_id=external_id, + external_post_id=platform_id, + author_username=comment.get("username", comment.get("from", {}).get("id", "unknown")), + author_name=comment.get("from", {}).get("name") if isinstance(comment.get("from"), dict) else None, + content=comment.get("text", comment.get("message")), + interaction_at=datetime.fromisoformat( + comment.get("created_at", comment.get("timestamp", comment.get("created_time", datetime.utcnow().isoformat()))).replace("Z", "+00:00") + ) if comment.get("created_at") or comment.get("timestamp") or comment.get("created_time") else datetime.utcnow() + ) + db.add(interaction) + new_comments += 1 + + db.commit() + + return f"{platform}: {new_mentions} menciones, {new_comments} comentarios nuevos" + + except Exception as e: + db.rollback() + return f"{platform}: error - {e}" + + finally: + db.close() diff --git a/worker/tasks/generate_content.py b/worker/tasks/generate_content.py new file mode 100644 index 0000000..b08df50 --- /dev/null +++ b/worker/tasks/generate_content.py @@ -0,0 +1,266 @@ +""" +Tareas de generación de contenido. +""" + +import asyncio +from datetime import datetime, timedelta + +from worker.celery_app import celery_app +from app.core.database import SessionLocal +from app.models.post import Post +from app.models.content_calendar import ContentCalendar +from app.models.tip_template import TipTemplate +from app.models.product import Product +from app.models.service import Service +from app.services.content_generator import content_generator + + +def run_async(coro): + """Helper para ejecutar coroutines en Celery.""" + loop = asyncio.get_event_loop() + return loop.run_until_complete(coro) + + +@celery_app.task(name="worker.tasks.generate_content.generate_scheduled_content") +def generate_scheduled_content(): + """ + Generar contenido según el calendario. + Se ejecuta cada hora. + """ + db = SessionLocal() + + try: + now = datetime.now() + current_day = now.weekday() + current_hour = now.hour + + # Obtener entradas del calendario para esta hora + entries = db.query(ContentCalendar).filter( + ContentCalendar.day_of_week == current_day, + ContentCalendar.is_active == True + ).all() + + for entry in entries: + # Verificar si es la hora correcta + if entry.time.hour != current_hour: + continue + + # Generar contenido según el tipo + if entry.content_type == "tip_tech": + generate_tip_post.delay( + platforms=entry.platforms, + category_filter=entry.category_filter, + requires_approval=entry.requires_approval + ) + + elif entry.content_type == "producto": + generate_product_post.delay( + platforms=entry.platforms, + requires_approval=entry.requires_approval + ) + + elif entry.content_type == "servicio": + generate_service_post.delay( + platforms=entry.platforms, + requires_approval=entry.requires_approval + ) + + return f"Procesadas {len(entries)} entradas del calendario" + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.generate_content.generate_tip_post") +def generate_tip_post( + platforms: list, + category_filter: str = None, + requires_approval: bool = False +): + """Generar un post de tip tech.""" + db = SessionLocal() + + try: + # Seleccionar un tip que no se haya usado recientemente + query = db.query(TipTemplate).filter( + TipTemplate.is_active == True + ) + + if category_filter: + query = query.filter(TipTemplate.category == category_filter) + + # Ordenar por uso (menos usado primero) + tip = query.order_by( + TipTemplate.used_count.asc(), + TipTemplate.last_used.asc().nullsfirst() + ).first() + + if not tip: + return "No hay tips disponibles" + + # Generar contenido para cada plataforma + content_by_platform = {} + for platform in platforms: + content = run_async( + content_generator.generate_tip_tech( + category=tip.category, + platform=platform, + template=tip.template + ) + ) + content_by_platform[platform] = content + + # Crear post + post = Post( + content=content_by_platform.get(platforms[0], ""), + content_type="tip_tech", + platforms=platforms, + content_x=content_by_platform.get("x"), + content_threads=content_by_platform.get("threads"), + content_instagram=content_by_platform.get("instagram"), + content_facebook=content_by_platform.get("facebook"), + status="pending_approval" if requires_approval else "scheduled", + scheduled_at=datetime.utcnow() + timedelta(minutes=5), + approval_required=requires_approval, + tip_template_id=tip.id + ) + + db.add(post) + + # Actualizar uso del tip + tip.used_count += 1 + tip.last_used = datetime.utcnow() + + db.commit() + + return f"Post de tip generado: {post.id}" + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.generate_content.generate_product_post") +def generate_product_post( + platforms: list, + product_id: int = None, + requires_approval: bool = True +): + """Generar un post de producto.""" + db = SessionLocal() + + try: + # Seleccionar producto + if product_id: + product = db.query(Product).filter(Product.id == product_id).first() + else: + # Seleccionar uno aleatorio que no se haya publicado recientemente + product = db.query(Product).filter( + Product.is_available == True + ).order_by( + Product.last_posted_at.asc().nullsfirst() + ).first() + + if not product: + return "No hay productos disponibles" + + # Generar contenido + content_by_platform = {} + for platform in platforms: + content = run_async( + content_generator.generate_product_post( + product=product.to_dict(), + platform=platform + ) + ) + content_by_platform[platform] = content + + # Crear post + post = Post( + content=content_by_platform.get(platforms[0], ""), + content_type="producto", + platforms=platforms, + content_x=content_by_platform.get("x"), + content_threads=content_by_platform.get("threads"), + content_instagram=content_by_platform.get("instagram"), + content_facebook=content_by_platform.get("facebook"), + status="pending_approval" if requires_approval else "scheduled", + scheduled_at=datetime.utcnow() + timedelta(minutes=5), + approval_required=requires_approval, + product_id=product.id, + image_url=product.main_image + ) + + db.add(post) + + # Actualizar última publicación del producto + product.last_posted_at = datetime.utcnow() + + db.commit() + + return f"Post de producto generado: {post.id}" + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.generate_content.generate_service_post") +def generate_service_post( + platforms: list, + service_id: int = None, + requires_approval: bool = True +): + """Generar un post de servicio.""" + db = SessionLocal() + + try: + # Seleccionar servicio + if service_id: + service = db.query(Service).filter(Service.id == service_id).first() + else: + service = db.query(Service).filter( + Service.is_active == True + ).order_by( + Service.last_posted_at.asc().nullsfirst() + ).first() + + if not service: + return "No hay servicios disponibles" + + # Generar contenido + content_by_platform = {} + for platform in platforms: + content = run_async( + content_generator.generate_service_post( + service=service.to_dict(), + platform=platform + ) + ) + content_by_platform[platform] = content + + # Crear post + post = Post( + content=content_by_platform.get(platforms[0], ""), + content_type="servicio", + platforms=platforms, + content_x=content_by_platform.get("x"), + content_threads=content_by_platform.get("threads"), + content_instagram=content_by_platform.get("instagram"), + content_facebook=content_by_platform.get("facebook"), + status="pending_approval" if requires_approval else "scheduled", + scheduled_at=datetime.utcnow() + timedelta(minutes=5), + approval_required=requires_approval, + service_id=service.id, + image_url=service.main_image + ) + + db.add(post) + + # Actualizar última publicación del servicio + service.last_posted_at = datetime.utcnow() + + db.commit() + + return f"Post de servicio generado: {post.id}" + + finally: + db.close() diff --git a/worker/tasks/publish_post.py b/worker/tasks/publish_post.py new file mode 100644 index 0000000..1191f5a --- /dev/null +++ b/worker/tasks/publish_post.py @@ -0,0 +1,156 @@ +""" +Tareas de publicación de posts. +""" + +import asyncio +from datetime import datetime + +from worker.celery_app import celery_app +from app.core.database import SessionLocal +from app.models.post import Post +from app.publishers import get_publisher + + +def run_async(coro): + """Helper para ejecutar coroutines en Celery.""" + loop = asyncio.get_event_loop() + return loop.run_until_complete(coro) + + +@celery_app.task(name="worker.tasks.publish_post.publish_scheduled_posts") +def publish_scheduled_posts(): + """ + Publicar posts que están programados para ahora. + Se ejecuta cada minuto. + """ + db = SessionLocal() + + try: + now = datetime.utcnow() + + # Obtener posts listos para publicar + posts = db.query(Post).filter( + Post.status == "scheduled", + Post.scheduled_at <= now + ).all() + + results = [] + + for post in posts: + # Marcar como en proceso + post.status = "publishing" + db.commit() + + # Publicar en cada plataforma + success_count = 0 + platform_ids = {} + errors = [] + + for platform in post.platforms: + result = publish_to_platform.delay(post.id, platform) + # En producción, esto sería asíncrono + # Por ahora, ejecutamos secuencialmente + + results.append(f"Post {post.id} enviado a publicación") + + return f"Procesados {len(posts)} posts" + + finally: + db.close() + + +@celery_app.task( + name="worker.tasks.publish_post.publish_to_platform", + bind=True, + max_retries=3, + default_retry_delay=60 +) +def publish_to_platform(self, post_id: int, platform: str): + """Publicar un post en una plataforma específica.""" + db = SessionLocal() + + try: + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + return f"Post {post_id} no encontrado" + + # Obtener contenido para esta plataforma + content = post.get_content_for_platform(platform) + + # Obtener publisher + try: + publisher = get_publisher(platform) + except ValueError as e: + return f"Error: {e}" + + # Publicar + result = run_async( + publisher.publish(content, post.image_url) + ) + + if result.success: + # Guardar ID del post en la plataforma + platform_ids = post.platform_post_ids or {} + platform_ids[platform] = result.post_id + post.platform_post_ids = platform_ids + + # Verificar si todas las plataformas están publicadas + all_published = all( + p in platform_ids for p in post.platforms + ) + + if all_published: + post.status = "published" + post.published_at = datetime.utcnow() + + db.commit() + + return f"Publicado en {platform}: {result.post_id}" + + else: + # Error en publicación + post.error_message = f"{platform}: {result.error_message}" + post.retry_count += 1 + + if post.retry_count >= 3: + post.status = "failed" + + db.commit() + + # Reintentar + raise self.retry( + exc=Exception(result.error_message), + countdown=60 * post.retry_count + ) + + except Exception as e: + db.rollback() + raise + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.publish_post.publish_now") +def publish_now(post_id: int): + """Publicar un post inmediatamente.""" + db = SessionLocal() + + try: + post = db.query(Post).filter(Post.id == post_id).first() + if not post: + return f"Post {post_id} no encontrado" + + # Cambiar estado + post.status = "publishing" + post.scheduled_at = datetime.utcnow() + db.commit() + + # Publicar en cada plataforma + for platform in post.platforms: + publish_to_platform.delay(post_id, platform) + + return f"Post {post_id} enviado a publicación inmediata" + + finally: + db.close()