feat: initial project setup
This commit is contained in:
36
agents/.env.example
Normal file
36
agents/.env.example
Normal 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
15
agents/arya/Dockerfile
Normal 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
96
agents/arya/main.py
Normal 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
42
agents/arya/prompt.txt
Normal 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
15
agents/bran/Dockerfile
Normal 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
96
agents/bran/main.py
Normal 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
40
agents/bran/prompt.txt
Normal 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
15
agents/bronn/Dockerfile
Normal 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
96
agents/bronn/main.py
Normal 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
46
agents/bronn/prompt.txt
Normal 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
|
||||
15
agents/daenerys/Dockerfile
Normal file
15
agents/daenerys/Dockerfile
Normal 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
96
agents/daenerys/main.py
Normal 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"])
|
||||
43
agents/daenerys/prompt.txt
Normal file
43
agents/daenerys/prompt.txt
Normal 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
15
agents/davos/Dockerfile
Normal 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
96
agents/davos/main.py
Normal 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
49
agents/davos/prompt.txt
Normal 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
134
agents/docker-compose.yml
Normal 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
15
agents/jon/Dockerfile
Normal 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
96
agents/jon/main.py
Normal 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
50
agents/jon/prompt.txt
Normal 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
15
agents/samwell/Dockerfile
Normal 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
96
agents/samwell/main.py
Normal 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
72
agents/samwell/prompt.txt
Normal 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/
|
||||
66
agents/shared/github_client.py
Normal file
66
agents/shared/github_client.py
Normal 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
15
agents/tyrion/Dockerfile
Normal 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
98
agents/tyrion/main.py
Normal 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
39
agents/tyrion/prompt.txt
Normal 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
15
agents/varys/Dockerfile
Normal 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
96
agents/varys/main.py
Normal 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
45
agents/varys/prompt.txt
Normal 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
|
||||
Reference in New Issue
Block a user