Cómo integrar Discord (webhook de eventos) al servidor de MU
Conecta el servidor de MU Online a Discord mediante webhooks para anunciar invasiones, resets, nuevos registros y estado del servidor automáticamente, con embeds bonitos, rate limit y cola de mensajes.
Integrar el servidor de MU Online a Discord transforma el canal de la comunidad en un mural vivo: cada invasión anunciada, cada reset celebrado, cada nuevo jugador registrado aparece automáticamente, sin que nadie escriba nada. La forma más directa y robusta de hacerlo es con webhooks: URLs que reci
Integrar el servidor de MU Online a Discord transforma el canal de la comunidad en un mural vivo: cada invasión anunciada, cada reset celebrado, cada nuevo jugador registrado aparece automáticamente, sin que nadie escriba nada. La forma más directa y robusta de hacerlo es con webhooks: URLs que reciben un POST en JSON y publican el mensaje en el canal. En este tutorial vas a crear el webhook, armar una biblioteca PHP reutilizable con embeds, ligar los disparos a eventos reales del servidor y protegerlo todo con cola y rate limit. Los valores concretos (colores, horarios, campos de la base) aparecen como ejemplo y varían según la versión.
Requisitos previos
Esta guía parte de un servidor ya funcional con sitio en PHP. Si todavía estás armando la base, mira cómo crear un servidor de MU Online y luego vuelve para enchufar Discord.
| Requisito | Detalle (ejemplo — varía según la versión) |
|---|---|
| Servidor Discord | Un servidor donde tengas permiso de gestionar webhooks |
| Canal de anuncios | Ej.: #anuncios o #eventos |
| PHP | 7.4+ con cURL habilitado |
| Acceso a la base | Lectura de la base de MU para detectar eventos |
| Cron o programador | Para chequear eventos periódicamente |
El único requisito no obvio es cURL habilitado en PHP, ya que es con él que hacemos el POST HTTPS a Discord. Confírmalo con php -m | grep curl en Linux o verificando la extensión en el php.ini en Windows.
Paso 1 — Crear el webhook en Discord
El webhook se crea dentro del propio Discord, no en código:
- Abre la configuración del canal deseado (el engranaje al lado del nombre del canal).
- Ve a Integraciones y luego a Webhooks.
- Haz clic en Nuevo Webhook, dale un nombre (ej.: "Servidor MU") y elige el canal.
- Haz clic en Copiar URL del Webhook. Tiene el formato
https://discord.com/api/webhooks/ID/TOKEN. - Guarda esa URL como un secreto: quien la tenga puede postear en tu canal.
Trata la URL del webhook como una contraseña. No debe aparecer en código versionado, en JavaScript en el navegador ni en un repositorio público. Vamos a guardarla en un archivo de configuración protegido.
Paso 2 — Guardar la configuración fuera del código
Crea un archivo config/discord.php fuera de la raíz pública, o al menos protegido de acceso directo. Guarda la URL y los parámetros generales:
<?php
// config/discord.php (mantener fuera del control de versiones)
return [
// URL copiada de Discord — NUNCA la expongas públicamente
'webhook_url' => 'https://discord.com/api/webhooks/ID/TOKEN',
// Nombre y avatar por defecto mostrados por los mensajes del webhook
'username' => 'Servidor MU',
'avatar_url' => 'https://tusitio.com/img/logo-discord.png',
// Colores por tipo de evento (entero decimal — ver Paso 4)
'cores' => [
'invasao' => 0xE67E22, // naranja
'reset' => 0x9B59B6, // morado
'registro' => 0x2ECC71, // verde
'status' => 0x3498DB, // azul
'alerta' => 0xE74C3C, // rojo
],
];
Paso 3 — Biblioteca PHP para enviar al webhook
Ahora la pieza central: una función que hace el POST con cURL, respeta el timeout y trata el rate limit (HTTP 429). Guárdala en lib/discord.php:
<?php
// lib/discord.php
/**
* Envía un payload al webhook de Discord.
* Devuelve el código HTTP; trata el 429 (rate limit) esperando el retry_after.
*/
function discordEnviar(array $payload, int $tentativas = 3): int {
$cfg = require __DIR__ . '/../config/discord.php';
$payload['username'] = $payload['username'] ?? $cfg['username'];
$payload['avatar_url'] = $payload['avatar_url'] ?? $cfg['avatar_url'];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
for ($i = 0; $i < $tentativas; $i++) {
$ch = curl_init($cfg['webhook_url']);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
]);
$resposta = curl_exec($ch);
$codigo = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 429 = rate limit: Discord dice cuánto esperar
if ($codigo === 429) {
$dados = json_decode($resposta, true);
$espera = (float) ($dados['retry_after'] ?? 1);
usleep((int) ($espera * 1_000_000) + 100_000);
continue; // intenta de nuevo
}
// 2xx = éxito (204 es el más común para webhook)
if ($codigo >= 200 && $codigo < 300) {
return $codigo;
}
// otro error: pequeña pausa y nuevo intento
usleep(500_000);
}
return $codigo ?? 0;
}
El tratamiento del HTTP 429 es lo que impide que tu integración sea bloqueada. Cuando excedes el límite, Discord responde con retry_after en segundos: la función espera ese tiempo e intenta de nuevo, en vez de martillar el endpoint.
Paso 4 — Armar embeds bonitos
Un mensaje de texto simple cumple la función, pero un embed —con color, título, thumbnail y campos— da una apariencia profesional. El color es un entero decimal (el hexadecimal 0xE67E22 es naranja). Vamos a crear un constructor:
<?php
// lib/discord.php (continuación)
/**
* Arma un embed estandarizado para eventos del servidor.
*/
function discordEmbed(
string $titulo,
string $descricao,
string $tipo = 'status',
array $campos = [],
?string $thumb = null
): array {
$cfg = require __DIR__ . '/../config/discord.php';
$cor = $cfg['cores'][$tipo] ?? 0x95A5A6;
$embed = [
'title' => $titulo,
'description' => $descricao,
'color' => $cor,
'timestamp' => date('c'), // ISO 8601, Discord formatea la hora
'footer' => ['text' => 'Servidor MU · Sistema automático'],
];
if ($thumb) {
$embed['thumbnail'] = ['url' => $thumb];
}
// Los campos aparecen en grilla: [['name'=>..,'value'=>..,'inline'=>true], ...]
if ($campos) {
$embed['fields'] = $campos;
}
return ['embeds' => [$embed]];
}
Con esto, enviar un anuncio se vuelve una línea:
<?php
require_once __DIR__ . '/lib/discord.php';
discordEnviar(discordEmbed(
'⚔️ ¡Invasión del Golden Dragon!',
'La invasión comenzó en **Lorencia**. ¡Corre a agarrar los drops!',
'invasao',
[
['name' => 'Mapa', 'value' => 'Lorencia', 'inline' => true],
['name' => 'Duración', 'value' => '20 min', 'inline' => true],
],
'https://tusitio.com/img/golden.png'
));
Paso 5 — Detectar eventos del servidor
El punto más importante de la arquitectura: no alteras el GameServer en C++. En su lugar, el backend PHP detecta el evento y dispara el webhook. Existen tres patrones de detección, del más simple al más integrado:
| Patrón | Cómo funciona | Cuándo usarlo |
|---|---|---|
| Agendado | El cron dispara en los horarios fijos de los eventos | Invasiones y eventos de horario conocido |
| Polling de base | El cron lee la base y detecta cambios (nuevos resets, registros) | Resets, nuevos registros, hitos |
| Endpoint interno | El sitio llama al webhook cuando una acción sucede en el propio sitio | Registro de cuenta, compra de VIP |
Ejemplo: anunciar invasiones por horario
Si tus eventos ocurren en horarios fijos (que varían según la versión y tu config), un cron cada minuto verifica si es hora de anunciar:
<?php
// cron/anunciar-invasoes.php (ejecutar vía cron cada 1 minuto)
require_once __DIR__ . '/../lib/discord.php';
// Horarios de EJEMPLO — ajústalos a los de tu GameServer
$agenda = [
'20:00' => ['nome' => 'Golden Invasion', 'mapa' => 'Devias'],
'22:00' => ['nome' => 'Golden Invasion', 'mapa' => 'Noria'],
'21:00' => ['nome' => 'Red Dragon', 'mapa' => 'Atlans'],
];
$horaAtual = date('H:i');
if (isset($agenda[$horaAtual])) {
$ev = $agenda[$horaAtual];
discordEnviar(discordEmbed(
'🐉 ¡' . $ev['nome'] . ' comenzó!',
'Invasión activa en **' . $ev['mapa'] . '**. ¡Buena cacería!',
'invasao',
[['name' => 'Mapa', 'value' => $ev['mapa'], 'inline' => true]]
));
}
Ejemplo: anunciar nuevos resets por polling
Para celebrar cuando alguien hace reset, guarda el último reset ya anunciado y verifica la base periódicamente:
<?php
// cron/anunciar-resets.php (ejecutar vía cron cada 2 minutos)
require_once __DIR__ . '/../lib/discord.php';
require_once __DIR__ . '/../lib/db.php'; // getMuDB()
$marcador = sys_get_temp_dir() . '/ultimo_reset_id.txt';
$ultimoId = is_file($marcador) ? (int) file_get_contents($marcador) : 0;
$conn = getMuDB();
// La tabla/columna de log de reset VARÍAN SEGÚN LA VERSIÓN — ajústalas a tu schema.
// Aquí suponemos una tabla ResetLog(id, Name, Resets, DataReset).
$sql = "SELECT TOP 10 id, Name, Resets
FROM ResetLog
WHERE id > ?
ORDER BY id ASC";
$stmt = sqlsrv_query($conn, $sql, [[$ultimoId, SQLSRV_PARAM_IN]]);
$maiorId = $ultimoId;
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
discordEnviar(discordEmbed(
'🔄 ¡Nuevo Reset!',
'¡**' . $row['Name'] . '** alcanzó el reset **' . $row['Resets'] . '**!',
'reset',
[['name' => 'Resets', 'value' => (string) $row['Resets'], 'inline' => true]]
));
$maiorId = max($maiorId, (int) $row['id']);
usleep(300_000); // pequeño respiro entre mensajes (rate limit)
}
file_put_contents($marcador, (string) $maiorId);
El archivo marcador es lo que evita anunciar el mismo reset dos veces. Sin él, cada ejecución del cron reenviaría todo.
Ejemplo: anunciar un nuevo registro desde el sitio
Cuando el evento sucede en el propio sitio (registro de cuenta), dispáralo directo en el flujo:
<?php
// dentro de tu proceso de registro, tras crear la cuenta con éxito
require_once __DIR__ . '/lib/discord.php';
discordEnviar(discordEmbed(
'🎉 ¡Nuevo jugador!',
'¡Bienvenido(a) al servidor! El total de cuentas crece cada día.',
'registro'
));
Paso 6 — Cola para respetar el rate limit
Si varios eventos disparan al mismo tiempo (diez resets en el mismo minuto), enviarlo todo de una vez revienta el rate limit. La solución profesional es una cola: los eventos entran en una tabla y un único worker envía con espaciado.
-- Cola de mensajes de Discord (MySQL de ejemplo)
CREATE TABLE discord_fila (
id INT AUTO_INCREMENT PRIMARY KEY,
payload TEXT NOT NULL, -- JSON del embed
status TINYINT NOT NULL DEFAULT 0, -- 0=pendiente,1=enviado,2=error
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_status (status, id)
);
<?php
// lib/discord-fila.php
// Encolar (llámalo en lugar de discordEnviar cuando haya volumen)
function discordEnfileirar(array $payload): void {
$pdo = getSiteDB();
$pdo->prepare("INSERT INTO discord_fila (payload) VALUES (:p)")
->execute([':p' => json_encode($payload, JSON_UNESCAPED_UNICODE)]);
}
// Worker (ejecutar vía cron cada 1 minuto) — envía con espaciado
function discordProcessarFila(int $maxPorRodada = 8): void {
$pdo = getSiteDB();
$itens = $pdo->query(
"SELECT id, payload FROM discord_fila WHERE status = 0 ORDER BY id ASC LIMIT $maxPorRodada"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($itens as $item) {
$codigo = discordEnviar(json_decode($item['payload'], true));
$novoStatus = ($codigo >= 200 && $codigo < 300) ? 1 : 2;
$pdo->prepare("UPDATE discord_fila SET status = :s WHERE id = :id")
->execute([':s' => $novoStatus, ':id' => $item['id']]);
usleep(400_000); // ~0,4s entre envíos para no reventar el límite
}
}
Con la cola, incluso una avalancha de eventos se vuelve un flujo controlado de mensajes, y ninguna se pierde por culpa del HTTP 429.
Paso 7 — Probar la integración
Antes de dejarlo corriendo, pruébalo manualmente con un script simple:
<?php
// teste-discord.php
require_once __DIR__ . '/lib/discord.php';
$codigo = discordEnviar(discordEmbed(
'✅ Prueba de integración',
'¡Si estás viendo esto en el canal, el webhook está funcionando!',
'status'
));
echo $codigo === 204 || ($codigo >= 200 && $codigo < 300)
? "OK — mensaje enviado (HTTP $codigo)\n"
: "Falló — HTTP $codigo\n";
Ejecútalo con php teste-discord.php. Un HTTP 204 significa éxito (Discord no devuelve cuerpo en un webhook exitoso, por eso 204 y no 200).
Errores comunes y soluciones
| Síntoma | Causa probable | Solución |
|---|---|---|
| No aparece nada en el canal | URL del webhook equivocada o canal eliminado | Recopia la URL en las integraciones del canal |
| HTTP 401/403 | Token del webhook inválido o revocado | Genera un nuevo webhook y actualiza la config |
| HTTP 429 constante | Muchos mensajes en poco tiempo | Implementa la cola y respeta retry_after |
| Acentos rotos | JSON sin JSON_UNESCAPED_UNICODE | Usa la flag al codificar el payload |
| Embed sin color | Color enviado como string hexa | Envía el color como entero decimal |
| Reset anunciado dos veces | Marcador de último ID no grabado | Verifica la escritura en el archivo/tabla marcador |
| cURL falla silenciosamente | Extensión cURL deshabilitada | Habilita curl en el php.ini |
Seguridad y buenas prácticas
- La URL del webhook es un secreto. Fuera del código versionado, fuera del navegador, fuera de un repositorio público. Quien la tiene, postea en tu canal.
- Siempre trata el 429. Respeta el
retry_afterde Discord; nunca reenvíes en un loop ciego. - Desacopla el juego de Discord. Detecta los eventos por la base/sitio, no alterando el GameServer.
- Usa cola bajo volumen. Varios eventos simultáneos exigen espaciado entre envíos.
- Idempotencia en los polls. Un marcador de "último procesado" evita anuncios duplicados.
- Timeout corto en cURL. Un Discord lento no puede trabar tu cron ni tu registro de cuenta.
Lista de verificación de lanzamiento
- Webhook creado y URL copiada con seguridad
- Config de Discord fuera del código versionado
- cURL habilitado en PHP confirmado
- Función de envío tratando el HTTP 429 con
retry_after - Embeds con color por tipo de evento
- Cron de invasiones en los horarios reales del GameServer
- Polling de resets con marcador de último ID
- Disparo de registro ligado al flujo del sitio
- Cola implementada para picos de eventos
- Worker de la cola corriendo vía cron con espaciado
- Prueba manual devolviendo HTTP 204
- Prueba real: forzar un evento y confirmar el mensaje en el canal
Con la integración lista, Discord se vuelve una extensión automática del servidor. Cada invasión se vuelve un llamado a la acción, cada reset se vuelve una celebración pública y cada nuevo jugador es recibido por la comunidad, todo sin esfuerzo manual, con código que respeta los límites de Discord y nunca pierde un mensaje.
Preguntas frecuentes
¿Webhook o bot: cuál usar para anunciar eventos?
Para enviar mensajes automáticas de eventos, el webhook es más simple y no exige mantener un bot online. El bot solo es necesario cuando necesitas que Discord reaccione a comandos o lea mensajes. Para anuncios de invasión, reset y estado, el webhook resuelve con mucho menos esfuerzo.
¿La URL del webhook puede quedar en el código del sitio?
Debe quedar fuera del código versionado, en un archivo de configuración protegido o en una variable de entorno. Quien tenga la URL puede postear en tu canal, así que trátala como un secreto. Nunca la expongas en JavaScript en el navegador ni en un repositorio público.
¿Con qué frecuencia puedo enviar mensajes al webhook?
Discord aplica rate limit. Como referencia, evita pasar de unas 5 peticiones en pocos segundos en el mismo webhook; los límites exactos varían y Discord devuelve HTTP 429 con un tiempo de espera cuando lo excedes. Implementa una cola y respeta el encabezado de retry.
¿Cómo hago que el servidor de juego dispare el webhook si es en C++?
Rara vez alteras el GameServer directamente. El patrón es que el sitio/backend detecte el evento (por lectura de la base, log o un endpoint) y dispare el webhook en PHP. Así desacoplas el juego de Discord y evitas tocar el core del servidor.
¿Puedo enviar embeds con color, imagen y campos?
Sí. El webhook acepta un array embeds con título, descripción, color (entero decimal), thumbnail, campos y pie de página. Es lo que le da al anuncio una apariencia profesional en vez de un texto simple.