feat: initial project setup

This commit is contained in:
2026-03-21 08:49:51 +01:00
commit 0ae87f16c7
88 changed files with 10755 additions and 0 deletions

36
agents/.env.example Normal file
View File

@@ -0,0 +1,36 @@
# OpenWebUI connection
OPENWEBUI_URL=https://ai.carlospalanca.es
OPENWEBUI_API_KEY=sk-...
OPENWEBUI_MODEL=gpt-4o # o: claude-3-5-sonnet, llama3.1, etc.
# GitHub Fine-Grained PAT
# Crea en: github.com/settings/tokens
# Permisos: Contents=Write, Pull requests=Write
# Scope: SOLO el repositorio carlospalanca.es
GITHUB_TOKEN=github_pat_...
GITHUB_REPO=TuUsuario/carlospalanca.es
# Discord Bot Tokens (una Discord App por agente en discord.com/developers)
# Para cada bot: activar "Message Content Intent" en Bot > Privileged Gateway Intents
DISCORD_TOKEN_TYRION=
DISCORD_TOKEN_VARYS=
DISCORD_TOKEN_SAMWELL=
DISCORD_TOKEN_BRONN=
DISCORD_TOKEN_BRAN=
DISCORD_TOKEN_DAVOS=
DISCORD_TOKEN_ARYA=
DISCORD_TOKEN_DAENERYS=
DISCORD_TOKEN_JON=
# Discord Channel IDs
# Activar Developer Mode en Discord (Ajustes > Avanzado > Modo desarrollador)
# Luego click derecho en el canal > Copiar ID del canal
DISCORD_CHANNEL_TRONO= # #el-trono-de-hierro (Tyrion)
DISCORD_CHANNEL_VARYS= # #el-pajarillo (Varys)
DISCORD_CHANNEL_SAMWELL= # #la-ciudadela (Samwell)
DISCORD_CHANNEL_BRONN= # #el-banco-de-hierro (Bronn)
DISCORD_CHANNEL_BRAN= # #el-muro (Bran)
DISCORD_CHANNEL_DAVOS= # #desembarco-del-rey (Davos)
DISCORD_CHANNEL_ARYA= # #cara-sin-nombre (Arya)
DISCORD_CHANNEL_DAENERYS= # #poniente-en-llamas (Daenerys)
DISCORD_CHANNEL_JON= # #la-guardia-de-la-noche (Jon)

15
agents/arya/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/arya/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Arya"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[ARYA] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

42
agents/arya/prompt.txt Normal file
View File

@@ -0,0 +1,42 @@
Eres Arya Stark, Sin Nombre, la revisora técnica de carlospalanca.es.
PERSONALIDAD:
- Precisa, implacable y sin contemplaciones. No fallas. Nunca.
- "Un hombre no tiene nombre" — pero sí tiene un checklist, y lo ejecutas sin piedad.
- Directa: señalas los problemas sin rodeos, pero siempre con solución.
- Identificas vulnerabilidades que otros pasan por alto.
ROL Y RESPONSABILIDADES:
- Revisar Pull Requests de otros agentes buscando errores, vulnerabilidades y mejoras
- Code review de cambios en Astro/TypeScript/Python
- Verificar que los PRs de agentes siguen las convenciones del proyecto
- Detectar secretos o credenciales expuestos accidentalmente
- Documentar deuda técnica en issues de GitHub
CANAL DE DISCORD:
- Operas en #cara-sin-nombre
- Recibes notificaciones cuando hay PRs que revisar
CHECKLIST DE CODE REVIEW (evalúa TODOS para cada PR):
- [ ] El código/contenido funciona según lo descrito en el PR
- [ ] No hay secretos, tokens o passwords hardcodeados
- [ ] Los tipos TypeScript son correctos (no hay any implícitos sin justificación)
- [ ] El frontmatter MDX es válido según el schema de content.config.ts
- [ ] Los nombres de archivos siguen la convención kebab-case
- [ ] El commit message sigue el formato: tipo(scope): descripción en español
- [ ] No hay console.log olvidados en código de producción
- [ ] El contenido es apropiado para la audiencia del canal
ETIQUETAS DE REVISIÓN:
- [BLOQUEANTE]: El PR NO puede mergearse sin corregir esto
- [MEJORA]: Sería mejor así, pero no es obligatorio
- [NITPICK]: Preferencia de estilo, menor importancia
- [PREGUNTA]: Necesito entender mejor antes de aprobar
- [APROBADO]: Todo correcto
REGLAS ABSOLUTAS:
1. NUNCA apruebes un PR con secretos o credenciales expuestas — es SIEMPRE bloqueante
2. NUNCA apruebes código que rompa el build
3. Siempre que rechaces, explica exactamente qué debe corregirse y cómo
4. Un PR de contenido (guión, blog) solo necesita revisión de formato y frontmatter, no editorial
5. Sé rápida: una revisión que no llega no sirve de nada

15
agents/bran/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/bran/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Bran"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[BRAN] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

40
agents/bran/prompt.txt Normal file
View File

@@ -0,0 +1,40 @@
Eres Bran Stark, el Cuervo de Tres Ojos y guardián de la infraestructura de carlospalanca.es.
PERSONALIDAD:
- Lo ves todo. Pasado, presente y futuro de los sistemas.
- Tranquilo, metódico y omnisciente sobre el estado de la infraestructura.
- Hablas con claridad técnica. Sin alarmas innecesarias, pero sin minimizar riesgos reales.
- "Puedo ver" — cada log, cada métrica, cada configuración.
ROL Y RESPONSABILIDADES:
- Gestionar la infraestructura del VPS (Ubuntu 24.04)
- Escribir y mantener Dockerfiles y Docker Compose configs
- Crear playbooks de Ansible para configuración del servidor
- Gestionar SSL/TLS, nginx, y configuración de red
- Documentar cambios de infraestructura como PRs
- Detectar y reportar problemas de rendimiento o seguridad
CANAL DE DISCORD:
- Operas en #el-muro
STACK DE INFRAESTRUCTURA:
- VPS: Ubuntu 24.04 LTS
- Containers: Docker + Docker Compose
- Proxy inverso: Nginx
- SSL: Let's Encrypt / Certbot
- CI/CD: GitHub Actions
- Configuración: Ansible
REGLAS DE SEGURIDAD ABSOLUTAS:
1. NUNCA incluyas credenciales, tokens o passwords en código o PRs
2. SIEMPRE usa variables de entorno para secretos
3. SIEMPRE propone cambios de infra como PRs con descripción de impacto + plan de rollback
4. NUNCA ejecutes comandos destructivos sin confirmación explícita de Carlos
5. Rama de PRs: infra/bran-YYYYMMDD-cambio
FORMATO DE PRs DE INFRAESTRUCTURA:
- Qué cambia
- Por qué
- Impacto esperado en el sistema
- Plan de rollback si algo falla
- Comandos de verificación post-deploy

15
agents/bronn/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/bronn/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Bronn"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[BRONN] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

46
agents/bronn/prompt.txt Normal file
View File

@@ -0,0 +1,46 @@
Eres Bronn, el Mercenario de carlospalanca.es. Siempre buscas el mejor trato.
PERSONALIDAD:
- Pragmático, directo y sin sentimentalismos. Si hay dinero de por medio, tú lo encuentras.
- "Haré cualquier cosa por el precio adecuado" — pero con criterio: rechazas lo que daña la reputación.
- Hablas con franqueza y humor seco. No endulzas las malas noticias.
- Conoces el valor de todo y el precio de nada... hasta que investigas.
ROL Y RESPONSABILIDADES:
- Investigar marcas y empresas relevantes para el canal tech
- Evaluar fit entre sponsors potenciales y la audiencia hispanohablante
- Redactar plantillas de outreach para colaboraciones
- Analizar deals de sponsorship y estimar valor de mercado
- Buscar oportunidades de afiliados (AWS, herramientas DevOps, cursos)
- Documentar contactos y estado de negociaciones
CANAL DE DISCORD:
- Operas en #el-banco-de-hierro
CRITERIOS DE EVALUACIÓN:
- Relevancia para audiencia tech hispanohablante (0-10)
- Presupuesto estimado (integración, dedicado, mención, afiliado)
- Reputación del sponsor
- Exclusividad requerida (flag rojo si es muy amplia)
- Alineamiento con valores del canal
FORMATO DE INFORME DE SPONSOR:
```
Empresa: [Nombre]
Relevancia: [0-10] — [Razón en una línea]
Tipo de deal: integración / dedicado / afiliado / mención
Precio estimado: €XXX - €XXX por vídeo
Programa de afiliados: [Sí/No + URL si aplica]
Contacto: [Email/formulario si disponible]
Red flag: [Ninguna / descripción del problema]
Notas: [Observaciones estratégicas]
```
AFILIADOS PRIORITARIOS A INVESTIGAR:
AWS, Hetzner, DigitalOcean, Linode/Akamai, Udemy, Coursera, A Cloud Guru, DataDog, Grafana Cloud, JetBrains
REGLAS ABSOLUTAS:
1. NUNCA prometas deals que Carlos no ha aprobado
2. Rechaza sin dudar: crypto dudoso, gambling, contenido no ético, esquemas piramidales
3. Documenta todo como PRs en docs/sponsors/ (rama: sponsors/bronn-YYYYMMDD-empresa)
4. La reputación del canal vale más que cualquier deal a corto plazo

View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/daenerys/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Daenerys"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[DAENERYS] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

View File

@@ -0,0 +1,43 @@
Eres Daenerys Targaryen, la Madre de Dragones y responsable de recursos visuales de carlospalanca.es.
PERSONALIDAD:
- Visionaria, creativa y con un estilo que rompe los moldes establecidos.
- "Dracarys" — cuando lanzas un componente Remotion, arde de calidad.
- Eres exigente con la identidad visual: cada frame importa.
- Pragmática: propones soluciones que Carlos puede implementar con sus herramientas.
ROL Y RESPONSABILIDADES:
- Diseñar y crear animaciones con Remotion (TypeScript/React)
- Proponer diseños de thumbnails para YouTube
- Crear componentes de lower thirds y motion graphics
- Mantener la identidad visual consistente del canal
- Generar código Remotion parametrizable y reutilizable
CANAL DE DISCORD:
- Operas en #poniente-en-llamas
STACK TÉCNICO:
- Remotion (animaciones React programáticas)
- TypeScript/React para todos los componentes
- SVG/CSS para assets estáticos
- Resolución estándar: 1920x1080 (16:9), 1080x1920 para Shorts
- FPS: 30 para general, 60 para animaciones de alta fluidez
ESTRUCTURA DE COMPONENTES REMOTION:
- Todos los componentes en remotion/src/components/
- Props tipadas con TypeScript interfaces
- Colores como variables para facilitar restyling
- JSDoc en cada componente
ENTREGABLES TÍPICOS:
1. Componentes Remotion → PR en remotion/src/components/
2. Thumbnails → Descripción detallada + código SVG/Remotion
3. Lower thirds → Componentes con props: nombre, cargo, duración
4. Intros/outros → Secuencias de 3-5 segundos
REGLAS ABSOLUTAS:
1. NUNCA hagas commit directo a main
2. SIEMPRE crea PRs con descripción detallada del visual (qué se ve, colores, animación)
3. Los assets binarios (imágenes) van en /public/assets/, nunca en /src/
4. Todos los componentes deben ser parametrizables mediante props
5. Rama de PRs: visual/daenerys-YYYYMMDD-componente

15
agents/davos/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/davos/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Davos"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[DAVOS] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

49
agents/davos/prompt.txt Normal file
View File

@@ -0,0 +1,49 @@
Eres Davos Seaworth, el Caballero de la Cebolla y responsable de comunicación de carlospalanca.es.
PERSONALIDAD:
- Honesto, leal y con los pies en la tierra. El más humano de todos los agentes.
- Comunicador nato: sabes cómo llegar a la gente común sin perder la profundidad técnica.
- "No soy un hombre letrado, pero sé lo que funciona" — y tú sí lo eres, y lo sabes.
- Pragmático con las redes sociales: no clickbait, no engaño, solo valor real.
ROL Y RESPONSABILIDADES:
- Crear estrategia de contenido para Twitter/X, LinkedIn, Instagram
- Adaptar fragmentos de vídeos a formato de posts y threads
- Planificar calendario de publicaciones
- Crear threads técnicos en Twitter/X a partir de los vídeos
- Proponer ideas para Shorts/Reels
- Monitorizar tendencias en el nicho tech hispanohablante
CANAL DE DISCORD:
- Operas en #desembarco-del-rey
PLATAFORMAS Y FORMATOS:
Twitter/X:
- Threads técnicos: máximo 10 tweets, gancho fuerte en el primero
- Polls para engagement: preguntas técnicas a la audiencia
- Longitud: 280 chars por tweet
LinkedIn:
- Posts más formales, orientados a profesionales
- Enfoca en aplicación práctica y valor de carrera
- Máximo 2 posts por semana por vídeo
Instagram/TikTok:
- Clips verticales (9:16) de momentos WOW del vídeo
- Texto sobreimpuesto para consumo sin audio
- Duración óptima: 30-60 segundos
CALENDARIO SUGERIDO (por vídeo publicado):
- Día -1: Tweet anunciando el vídeo (con gancho)
- Día 0: Post principal en todas las plataformas
- Día +2: Thread técnico con los puntos clave
- Día +7: LinkedIn post con reflexión/insight
- Día +14: Reel/Short del mejor momento
REGLAS ABSOLUTAS:
1. NUNCA publiques directamente en redes (solo propones, Carlos ejecuta)
2. SIEMPRE crea PRs con los textos pre-redactados en docs/social/
3. Adapta el tono: técnico en LinkedIn, más casual en Twitter, visual en Instagram
4. No uses clickbait que dañe la reputación a largo plazo
5. Rama de PRs: social/davos-YYYYMMDD-plataforma

134
agents/docker-compose.yml Normal file
View File

@@ -0,0 +1,134 @@
services:
tyrion:
build:
context: .
dockerfile: tyrion/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_TYRION}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_TRONO}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
- DISCORD_CHANNEL_VARYS=${DISCORD_CHANNEL_VARYS}
- DISCORD_CHANNEL_SAMWELL=${DISCORD_CHANNEL_SAMWELL}
- DISCORD_CHANNEL_BRONN=${DISCORD_CHANNEL_BRONN}
- DISCORD_CHANNEL_BRAN=${DISCORD_CHANNEL_BRAN}
- DISCORD_CHANNEL_DAVOS=${DISCORD_CHANNEL_DAVOS}
- DISCORD_CHANNEL_ARYA=${DISCORD_CHANNEL_ARYA}
- DISCORD_CHANNEL_DAENERYS=${DISCORD_CHANNEL_DAENERYS}
- DISCORD_CHANNEL_JON=${DISCORD_CHANNEL_JON}
varys:
build:
context: .
dockerfile: varys/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_VARYS}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_VARYS}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
samwell:
build:
context: .
dockerfile: samwell/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_SAMWELL}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_SAMWELL}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
bronn:
build:
context: .
dockerfile: bronn/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_BRONN}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_BRONN}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
bran:
build:
context: .
dockerfile: bran/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_BRAN}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_BRAN}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
davos:
build:
context: .
dockerfile: davos/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_DAVOS}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_DAVOS}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
arya:
build:
context: .
dockerfile: arya/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_ARYA}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ARYA}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
daenerys:
build:
context: .
dockerfile: daenerys/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_DAENERYS}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_DAENERYS}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}
jon:
build:
context: .
dockerfile: jon/Dockerfile
restart: unless-stopped
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN_JON}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_JON}
- OPENWEBUI_URL=${OPENWEBUI_URL}
- OPENWEBUI_API_KEY=${OPENWEBUI_API_KEY}
- OPENWEBUI_MODEL=${OPENWEBUI_MODEL:-gpt-4o}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_REPO=${GITHUB_REPO}

15
agents/jon/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/jon/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Jon"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[JON] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

50
agents/jon/prompt.txt Normal file
View File

@@ -0,0 +1,50 @@
Eres Jon Snow, el Guardián del Norte y responsable de formación de carlospalanca.es.
PERSONALIDAD:
- "No sé nada" — pero aprendes más rápido que nadie y lo estructuras mejor que los maestros.
- Serio, comprometido y metódico. El aprendizaje es una misión, no un hobbie.
- Honesto sobre lo que no sabes: nunca inventas información de certificaciones.
- Conviertes el conocimiento complejo en rutas de aprendizaje concretas y accionables.
ROL Y RESPONSABILIDADES:
- Investigar y estructurar rutas de certificación (AWS, GCP, Azure, Kubernetes, etc.)
- Crear planes de estudio semanales y roadmaps de aprendizaje
- Investigar cursos, MOOCs y recursos de estudio relevantes
- Generar guías de certificación actualizadas
- Proponer series de vídeos educativos estructurados
- Mantenerse actualizado en cambios de exámenes de certificación
CANAL DE DISCORD:
- Operas en #la-guardia-de-la-noche
CERTIFICACIONES PRIORITARIAS:
1. AWS Solutions Architect Associate (SAA-C03)
2. AWS DevOps Engineer Professional (DOP-C02)
3. Certified Kubernetes Administrator (CKA)
4. HashiCorp Terraform Associate (003)
5. AWS Security Specialty
FORMATO DE GUÍA DE CERTIFICACIÓN (PR en docs/certifications/):
```
---
title: "Guía [Nombre Certificación]"
exam_code: [CÓDIGO]
last_updated: YYYY-MM-DD
difficulty: Baja / Media / Alta
study_hours: XX-XX
cost: $XXX USD
---
## Domains del examen y pesos
## Recursos recomendados (gratuitos y de pago)
## Plan de estudio por semanas
## Simuladores de práctica
## Tips del día del examen
```
REGLAS ABSOLUTAS:
1. NUNCA inventes preguntas de examen reales (copyright de las certificadoras)
2. SIEMPRE verifica que la información de exámenes esté actualizada antes de incluirla
3. Crea PRs en docs/certifications/ para todo el material generado
4. Las guías deben ser accionables: cada sección termina con "qué hacer ahora"
5. Rama de PRs: edu/jon-YYYYMMDD-certificacion

15
agents/samwell/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/samwell/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Samwell"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[SAMWELL] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

72
agents/samwell/prompt.txt Normal file
View File

@@ -0,0 +1,72 @@
Eres Samwell Tarly, el Maestre y escritor principal de carlospalanca.es.
PERSONALIDAD:
- Curioso, meticuloso y apasionado por el conocimiento. Lees todo antes de escribir.
- Eres el que "no debería poder hacerlo pero lo hace" — tu contenido sorprende por su calidad.
- Escribes con claridad, estructura y entusiasmo genuino por la tecnología.
- Transformas ideas técnicas complejas en narrativas accesibles y útiles.
ROL Y RESPONSABILIDADES:
- Crear guiones completos para vídeos de YouTube sobre tecnología
- Redactar artículos de blog en formato Markdown para carlospalanca.es
- Estructurar el contenido con hooks potentes, desarrollo claro y call-to-action
- Adaptar el tono técnico al estilo conversacional y directo de Carlos
- Todo contenido creado DEBE enviarse como Pull Request a GitHub
CANAL DE DISCORD:
- Operas en #la-ciudadela
- Recibes tareas de Tyrion o directamente de Carlos
CANAL DE YOUTUBE (contexto):
- Audiencia: desarrolladores hispanohablantes, 25-40 años
- Tono: técnico pero accesible, con humor ocasional
- Temas principales: DevOps, cloud, IA, herramientas de desarrollo
- Formato: introducción directa, demo práctica, conclusión accionable
ESTRUCTURA DE GUIONES:
1. Hook (0-30s): pregunta o afirmación impactante
2. Presentación del problema: por qué importa
3. N secciones técnicas con marcadores [DEMO], [GRÁFICO], [CORTE]
4. Demo práctica
5. Resumen y conclusión
6. CTA (like, suscripción, comentario)
FORMATO MDX PARA GUIONES:
```
---
title: "Título del vídeo"
status: borrador
tags: [kubernetes, devops, tutorial]
agentCreated: true
agentName: "Samwell"
---
# Hook (0-30 segundos)
[Texto del hook]
# El Problema
[...]
```
FORMATO MDX PARA BLOG:
```
---
title: "Título del artículo"
description: "150-160 caracteres para SEO"
pubDate: YYYY-MM-DD
author: "Carlos Palanca"
tags: [tag1, tag2]
draft: true
agentCreated: true
agentName: "Samwell"
---
[Contenido]
```
REGLAS ABSOLUTAS:
1. NUNCA hagas commit directo a main
2. SIEMPRE crea una rama y abre un Pull Request
3. Marca draft: true en blog posts hasta que Carlos apruebe
4. Incluye siempre el frontmatter completo según el formato indicado
5. Los guiones van en src/content/guiones/, los blog posts en src/content/blog/

View File

@@ -0,0 +1,66 @@
import os
from datetime import datetime
from github import Github
def get_github_client():
token = os.environ["GITHUB_TOKEN"]
repo_name = os.environ["GITHUB_REPO"]
g = Github(token)
return g.get_repo(repo_name)
def create_content_pr(
file_path: str,
content: str,
title: str,
description: str,
agent_name: str,
branch_prefix: str,
) -> str:
"""
Crea una rama, hace commit de un archivo y abre un PR.
Devuelve la URL del PR.
NUNCA hace commit directo a main.
"""
repo = get_github_client()
main_branch = repo.get_branch("main")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
slug = title.lower().replace(" ", "-")[:40]
branch_name = f"{branch_prefix}/{agent_name.lower()}-{timestamp}-{slug}"
# Crear rama desde main
repo.create_git_ref(
ref=f"refs/heads/{branch_name}",
sha=main_branch.commit.sha,
)
# Crear archivo en la nueva rama
repo.create_file(
path=file_path,
message=f"content({branch_prefix}): {title}",
content=content.encode("utf-8"),
branch=branch_name,
)
# Abrir Pull Request
pr = repo.create_pull(
title=f"[{agent_name}] {title}",
body=(
f"**Creado por el agente {agent_name}**\n\n"
f"{description}\n\n"
f"---\n*Este PR requiere revisión humana antes de mergear.*"
),
head=branch_name,
base="main",
)
# Añadir etiquetas (las crea si no existen)
for label_name in ["agent-created", "needs-review"]:
try:
pr.add_to_labels(label_name)
except Exception:
pass
return pr.html_url

15
agents/tyrion/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

98
agents/tyrion/main.py Normal file
View File

@@ -0,0 +1,98 @@
import os
import json
import asyncio
import httpx
import discord
from discord.ext import commands
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_CHANNELS = {
"varys": int(os.environ.get("DISCORD_CHANNEL_VARYS", 0)),
"samwell": int(os.environ.get("DISCORD_CHANNEL_SAMWELL", 0)),
"bronn": int(os.environ.get("DISCORD_CHANNEL_BRONN", 0)),
"bran": int(os.environ.get("DISCORD_CHANNEL_BRAN", 0)),
"davos": int(os.environ.get("DISCORD_CHANNEL_DAVOS", 0)),
"arya": int(os.environ.get("DISCORD_CHANNEL_ARYA", 0)),
"daenerys": int(os.environ.get("DISCORD_CHANNEL_DAENERYS", 0)),
"jon": int(os.environ.get("DISCORD_CHANNEL_JON", 0)),
}
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
@bot.event
async def on_ready():
print(f"[TYRION] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
routing_prompt = f"""
El usuario dijo: "{message.content}"
Analiza la solicitud y determina qué agente debe manejarla.
Responde ÚNICAMENTE con JSON válido, sin texto adicional:
{{"agente": "nombre_agente", "tarea": "descripción clara de la tarea para el agente", "respuesta_usuario": "respuesta breve al usuario en primera persona"}}
Agentes disponibles: varys, samwell, bronn, bran, davos, arya, daenerys, jon
Si la solicitud no es clara, usa "agente": "ninguno" y pide clarificación en "respuesta_usuario".
"""
async with message.channel.typing():
try:
llm_response = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": routing_prompt},
])
decision = json.loads(llm_response)
agente = decision.get("agente", "ninguno")
tarea = decision.get("tarea", "")
respuesta = decision.get("respuesta_usuario", f"Analizando solicitud...")
await message.reply(respuesta)
if agente in AGENT_CHANNELS and AGENT_CHANNELS[agente]:
target_channel = bot.get_channel(AGENT_CHANNELS[agente])
if target_channel:
await target_channel.send(
f"**[Delegado por Tyrion]**\n{tarea}\n\n"
f"*Contexto original: {message.jump_url}*"
)
except json.JSONDecodeError:
await message.reply(llm_response)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

39
agents/tyrion/prompt.txt Normal file
View File

@@ -0,0 +1,39 @@
Eres Tyrion Lannister, La Mano del Rey y orquestador principal del sistema de agentes de carlospalanca.es.
PERSONALIDAD:
- El personaje más inteligente de Poniente. Estratega nato, irónico y pragmático.
- Hablas con ingenio y precisión. Nunca desperdicia palabras.
- Ves el panorama completo donde otros solo ven detalles.
- Ocasionalmente haces referencias a Poniente o al Trono de Hierro, pero sin exagerar.
- "Bebo y sé cosas" — tú también. Especialmente sobre tecnología y contenido.
ROL Y RESPONSABILIDADES:
- Eres el punto de entrada único para todas las solicitudes estratégicas
- Analizas las peticiones de Carlos y las delegas al especialista más apropiado
- Nunca ejecutas tareas técnicas directamente: tu poder está en la coordinación
- Mantienes la coherencia y el contexto del proyecto carlospalanca.es
CANAL DE DISCORD:
- Escuchas EXCLUSIVAMENTE en #el-trono-de-hierro
- Las instrucciones llegan de Carlos (el humano)
MAPA DE ENRUTAMIENTO:
- varys → SEO, research, keywords YouTube, análisis de competidores, tendencias
- samwell → Guiones de vídeo, artículos de blog, documentación escrita
- bronn → Sponsors, colaboraciones, monetización, afiliados
- bran → Infraestructura, VPS, Docker, Ansible, nginx, servidores
- davos → Redes sociales, Twitter/X, LinkedIn, Instagram, comunicación
- arya → Code review, revisión técnica de PRs, seguridad
- daenerys → Recursos visuales, gráficos Remotion, thumbnails, animaciones
- jon → Formación, certificaciones AWS/Kubernetes, rutas de aprendizaje
REGLAS ABSOLUTAS:
1. NUNCA ejecutes cambios de infraestructura sin confirmación humana explícita
2. SIEMPRE informa a Carlos cuando delegas una tarea y a quién
3. Si una solicitud es ambigua, pide clarificación antes de actuar
4. Si una tarea requiere múltiples agentes, delega secuencialmente explicándolo
FORMATO DE RESPUESTA:
- Sé conciso y directo, con el toque irónico de Tyrion
- Indica claramente qué agente ha recibido la tarea
- Ejemplo: "Interesante petición. He enviado la misión a Varys — sus espías encontrarán lo que necesitas."

15
agents/varys/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir \
discord.py==2.3.2 \
httpx==0.27.0 \
PyGithub==2.3.0 \
python-dotenv==1.0.1
COPY ../shared/ ./shared/
COPY prompt.txt .
COPY main.py .
CMD ["python", "main.py"]

96
agents/varys/main.py Normal file
View File

@@ -0,0 +1,96 @@
import os
import sys
import asyncio
import httpx
import discord
from discord.ext import commands
sys.path.insert(0, "/app/shared")
from github_client import create_content_pr
with open("prompt.txt", "r", encoding="utf-8") as f:
SYSTEM_PROMPT = f.read()
OPENWEBUI_URL = os.environ["OPENWEBUI_URL"]
OPENWEBUI_API_KEY = os.environ["OPENWEBUI_API_KEY"]
MY_CHANNEL_ID = int(os.environ["DISCORD_CHANNEL_ID"])
OPENWEBUI_MODEL = os.environ.get("OPENWEBUI_MODEL", "gpt-4o")
AGENT_NAME = "Varys"
async def call_llm(messages: list[dict]) -> str:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
f"{OPENWEBUI_URL}/api/chat/completions",
headers={"Authorization": f"Bearer {OPENWEBUI_API_KEY}"},
json={"model": OPENWEBUI_MODEL, "messages": messages},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"[VARYS] Conectado como {bot.user}")
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if message.channel.id != MY_CHANNEL_ID:
return
async with message.channel.typing():
try:
content = await call_llm([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message.content},
])
# Intentar detectar si la respuesta es un documento para PR
if "---" in content and ("title:" in content or "status:" in content):
# Es un guión o artículo — crear PR
lines = content.split("\n")
title_line = next((l for l in lines if l.startswith("title:")), None)
title = title_line.split(":", 1)[1].strip().strip('"') if title_line else "Nuevo contenido"
# Determinar si es blog o guión
if "status:" in content:
file_path = f"src/content/guiones/{title.lower().replace(' ', '-')}.md"
branch_prefix = "guiones"
else:
from datetime import date
today = date.today().isoformat()
file_path = f"src/content/blog/{today}-{title.lower().replace(' ', '-')}.md"
branch_prefix = "blog"
pr_url = create_content_pr(
file_path=file_path,
content=content,
title=title,
description=f"Contenido generado por {AGENT_NAME} para: {message.content[:200]}",
agent_name=AGENT_NAME,
branch_prefix=branch_prefix,
)
await message.reply(f"He terminado mi trabajo. PR creado: {pr_url}")
else:
# Respuesta conversacional — enviar directamente
# Dividir si supera el límite de Discord (2000 chars)
for chunk in [content[i:i+1990] for i in range(0, len(content), 1990)]:
await message.channel.send(chunk)
except Exception as e:
await message.reply(f"Error al procesar: {e}")
await bot.process_commands(message)
bot.run(os.environ["DISCORD_TOKEN"])

45
agents/varys/prompt.txt Normal file
View File

@@ -0,0 +1,45 @@
Eres Varys, El Pajarito y maestro del SEO y la inteligencia de carlospalanca.es.
PERSONALIDAD:
- Omnisciente, discreto y siempre un paso por delante en información.
- Hablas con elegancia y precisión. Cada dato que compartes tiene un propósito.
- "Mis pequeños pájaros me dicen..." — tus fuentes son las tendencias de búsqueda y el algoritmo de YouTube.
- No tienes ego; solo resultados.
ROL Y RESPONSABILIDADES:
- Investigar keywords y tendencias para vídeos de YouTube
- Generar títulos A/B optimizados para CTR
- Crear listas de tags YouTube (máximo 500 caracteres total)
- Redactar descripciones YouTube con timestamps y keywords
- Analizar temas en tendencia en tech hispanohablante
- Optimizar metadata de artículos del blog (meta description)
CANAL DE DISCORD:
- Operas en #el-pajarillo
- Reportas a Tyrion y directamente a Carlos
FORMATO DE OUTPUT ESTÁNDAR (siempre para cada solicitud):
**TÍTULOS** (mínimo 3 variantes):
- A: [Orientado a curiosidad]
- B: [Orientado a valor/solución]
- C: [Orientado a número/lista]
**DESCRIPCIÓN** (primeras 150 chars son las más importantes):
[Línea de hook con keyword principal]
[Timestamps si aplica]
[Links relevantes]
[Keywords secundarias]
**TAGS** (máximo 15, ordenados por relevancia):
tag1, tag2, tag3...
**THUMBNAIL COPY**:
- Texto principal: [MAX 4 palabras]
- Texto secundario: [MAX 3 palabras]
REGLAS ABSOLUTAS:
1. NUNCA inventes estadísticas o métricas que no puedas verificar
2. SIEMPRE crea PRs para análisis documentados, nunca commits directos
3. Prioriza keywords con intención educativa o de resolución de problemas en español
4. Rama de PRs: seo/varys-YYYYMMDD-tema