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 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 01:11:44 +00:00
commit 049d2133f9
53 changed files with 5876 additions and 0 deletions

View File

@@ -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 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git config:*)"
]
}
}

74
.env.example Normal file
View File

@@ -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

91
.gitignore vendored Normal file
View File

@@ -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

50
Dockerfile Normal file
View File

@@ -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"]

160
README.md Normal file
View File

@@ -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

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Social Media Automation - Consultoría AS

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
# API Routes

View File

@@ -0,0 +1 @@
# API Route modules

201
app/api/routes/calendar.py Normal file
View File

@@ -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
}

118
app/api/routes/dashboard.py Normal file
View File

@@ -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
})

View File

@@ -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}

216
app/api/routes/posts.py Normal file
View File

@@ -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}

180
app/api/routes/products.py Normal file
View File

@@ -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
}

174
app/api/routes/services.py Normal file
View File

@@ -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
}

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Core module

60
app/core/config.py Normal file
View File

@@ -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()

31
app/core/database.py Normal file
View File

@@ -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()

78
app/core/security.py Normal file
View File

@@ -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}

71
app/main.py Normal file
View File

@@ -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
}

24
app/models/__init__.py Normal file
View File

@@ -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"
]

View File

@@ -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"<ContentCalendar {self.day_of_week} {self.time} - {self.content_type}>"
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
}

View File

@@ -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"<ImageTemplate {self.name}>"
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
}

87
app/models/interaction.py Normal file
View File

@@ -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"<Interaction {self.platform} - {self.interaction_type} - {self.author_username}>"
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
}

134
app/models/post.py Normal file
View File

@@ -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"<Post {self.id} - {self.status}>"
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

80
app/models/product.py Normal file
View File

@@ -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"<Product {self.name}>"
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
}

90
app/models/service.py Normal file
View File

@@ -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"<Service {self.name}>"
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
}

View File

@@ -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"<TipTemplate {self.title}>"
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
}

View File

@@ -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

View File

@@ -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"
]

74
app/publishers/base.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Services module

View File

@@ -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()

View File

@@ -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()

View File

@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Social Media Automation</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
body { background-color: #1a1a2e; color: #eee; }
.card { background-color: #16213e; border-radius: 12px; }
.accent { color: #d4a574; }
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
.btn-primary:hover { background-color: #c49564; }
</style>
</head>
<body class="min-h-screen">
<!-- Header -->
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">
<span class="accent">Consultoría AS</span> - Social Media
</h1>
<nav class="flex gap-4">
<a href="/" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
<a href="/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
<a href="/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
<a href="/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
<a href="/products" class="px-4 py-2 rounded hover:bg-gray-800">Productos</a>
</nav>
</div>
</header>
<main class="container mx-auto px-6 py-8">
<!-- Stats -->
<div class="grid grid-cols-5 gap-4 mb-8">
<div class="card p-4 text-center">
<div class="text-3xl font-bold accent">{{ stats.posts_today }}</div>
<div class="text-gray-400 text-sm">Posts Hoy</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold accent">{{ stats.posts_week }}</div>
<div class="text-gray-400 text-sm">Posts Semana</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-yellow-500">{{ stats.pending_approval }}</div>
<div class="text-gray-400 text-sm">Pendientes</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-blue-500">{{ stats.scheduled }}</div>
<div class="text-gray-400 text-sm">Programados</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-red-500">{{ stats.interactions_pending }}</div>
<div class="text-gray-400 text-sm">Interacciones</div>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<!-- Pending Approval -->
<div class="card p-6">
<h2 class="text-xl font-semibold mb-4">Pendientes de Aprobación</h2>
{% if pending_posts %}
{% for post in pending_posts %}
<div class="border-b border-gray-700 py-4 last:border-0">
<div class="flex justify-between items-start mb-2">
<span class="bg-blue-900 text-blue-300 px-2 py-1 rounded text-xs">
{{ post.content_type }}
</span>
<span class="text-gray-500 text-sm">
{{ post.scheduled_at }}
</span>
</div>
<p class="text-gray-300 mb-3">{{ post.content[:200] }}...</p>
<div class="flex gap-2">
<button onclick="approvePost({{ post.id }})"
class="btn-primary px-3 py-1 rounded text-sm">
Aprobar
</button>
<button onclick="rejectPost({{ post.id }})"
class="bg-red-900 text-red-300 px-3 py-1 rounded text-sm">
Rechazar
</button>
<button onclick="editPost({{ post.id }})"
class="bg-gray-700 px-3 py-1 rounded text-sm">
Editar
</button>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-gray-500">No hay posts pendientes</p>
{% endif %}
</div>
<!-- Scheduled -->
<div class="card p-6">
<h2 class="text-xl font-semibold mb-4">Próximas Publicaciones</h2>
{% if scheduled_posts %}
{% for post in scheduled_posts %}
<div class="border-b border-gray-700 py-3 last:border-0 flex items-center gap-4">
<div class="text-center">
<div class="text-accent font-bold">{{ post.scheduled_at }}</div>
</div>
<div class="flex-1">
<span class="text-xs bg-gray-700 px-2 py-1 rounded">
{{ post.content_type }}
</span>
<p class="text-gray-400 text-sm mt-1">
{{ post.content[:100] }}...
</p>
</div>
<div class="flex gap-1">
{% for platform in post.platforms %}
<span class="text-xs bg-gray-800 px-2 py-1 rounded">
{{ platform }}
</span>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-gray-500">No hay posts programados</p>
{% endif %}
</div>
</div>
<!-- Recent Interactions -->
<div class="card p-6 mt-6">
<h2 class="text-xl font-semibold mb-4">Interacciones Recientes</h2>
{% if recent_interactions %}
<div class="grid gap-4">
{% for interaction in recent_interactions %}
<div class="bg-gray-800 rounded-lg p-4">
<div class="flex justify-between items-start mb-2">
<div>
<span class="font-semibold">@{{ interaction.author_username }}</span>
<span class="text-gray-500 text-sm ml-2">{{ interaction.platform }}</span>
</div>
<span class="text-gray-500 text-xs">{{ interaction.interaction_at }}</span>
</div>
<p class="text-gray-300">{{ interaction.content }}</p>
<div class="mt-3 flex gap-2">
<button class="bg-accent text-gray-900 px-3 py-1 rounded text-sm">
Responder
</button>
<button class="bg-gray-700 px-3 py-1 rounded text-sm">
Marcar como Lead
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500">No hay interacciones pendientes</p>
{% endif %}
</div>
</main>
<script>
async function approvePost(postId) {
const response = await fetch(`/api/posts/${postId}/approve`, { method: 'POST' });
if (response.ok) {
location.reload();
}
}
async function rejectPost(postId) {
if (confirm('¿Seguro que quieres rechazar este post?')) {
const response = await fetch(`/api/posts/${postId}/reject`, { method: 'POST' });
if (response.ok) {
location.reload();
}
}
}
function editPost(postId) {
window.location.href = `/posts/${postId}/edit`;
}
</script>
</body>
</html>

122
data/tips_iniciales.json Normal file
View File

@@ -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"
}
]

182
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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.

92
nginx/nginx.conf Normal file
View File

@@ -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 ...
# }
}

48
requirements.txt Normal file
View File

@@ -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

24
scripts/init_db.sql Normal file
View File

@@ -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
$$;

180
scripts/seed_database.py Normal file
View File

@@ -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()

127
templates/tip_card.html Normal file
View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html>
<head>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 1080px;
height: 1080px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
font-family: 'Segoe UI', Arial, sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 60px;
color: #fff;
}
.category {
background: rgba(212, 165, 116, 0.2);
color: #d4a574;
padding: 12px 24px;
border-radius: 30px;
font-size: 18px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 40px;
}
.icon {
font-size: 80px;
margin-bottom: 30px;
}
.title {
font-size: 42px;
font-weight: 700;
text-align: center;
margin-bottom: 30px;
line-height: 1.3;
color: #fff;
}
.content {
font-size: 32px;
text-align: center;
line-height: 1.6;
color: #e0e0e0;
max-width: 900px;
margin-bottom: 60px;
}
.footer {
position: absolute;
bottom: 40px;
display: flex;
align-items: center;
gap: 20px;
}
.logo {
width: 50px;
height: 50px;
background: #d4a574;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 24px;
color: #1a1a2e;
}
.brand {
font-size: 20px;
color: #888;
}
.brand span {
color: #d4a574;
font-weight: 600;
}
.decoration {
position: absolute;
width: 300px;
height: 300px;
border-radius: 50%;
background: radial-gradient(circle, rgba(212, 165, 116, 0.1) 0%, transparent 70%);
}
.decoration-1 {
top: -100px;
right: -100px;
}
.decoration-2 {
bottom: -100px;
left: -100px;
}
</style>
</head>
<body>
<div class="decoration decoration-1"></div>
<div class="decoration decoration-2"></div>
<div class="category">💡 {{ category }}</div>
<h1 class="title">{{ title }}</h1>
<p class="content">{{ content }}</p>
<div class="footer">
<div class="logo">AS</div>
<div class="brand">
<span>{{ business_name }}</span><br>
{{ website }}
</div>
</div>
</body>
</html>

1
worker/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Celery Worker

61
worker/celery_app.py Normal file
View File

@@ -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),
},
}

1
worker/tasks/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Celery Tasks

86
worker/tasks/cleanup.py Normal file
View File

@@ -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}"

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()