Crea un bot de picks para Discord con cuotas de Pinnacle en vivo
Este bot vigila los precios de Pinnacle en el WebSocket de PinnFeed y publica un embed en un canal de Discord cada vez que un mercado hace steam: su cuota baja un 5% o más en dos minutos en un mercado con una apuesta máxima de al menos 100. Es un solo archivo de unas 90 líneas. No necesita librería de Discord ni cuenta de bot, porque publica mediante un webhook entrante del canal.
Es el gemelo en Discord del bot de picks para Telegram. La regla de detección es la que explica la guía de detección de steam. Es una herramienta educativa para mayores de 18 años, no pronósticos.
Paso 1 — Crea el webhook#
- En tu servidor de Discord, abre Ajustes del servidor → Integraciones → Webhooks y elige Nuevo webhook.
- Ponle nombre, elige el canal donde publicará y usa Copiar URL del webhook.
- Trata esa URL como una contraseña: quien la tenga puede publicar en el canal. Si se filtra, borra el webhook y crea otro.
Paso 2 — Configura el entorno#
export PINNFEED_KEY="ps_live_..." # plan Builder (WebSocket)
export DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." # del paso 1
npm install ws
Si dejas DISCORD_WEBHOOK sin definir, el bot funciona en modo de prueba: imprime cada alerta en vez de publicarla.
El stream necesita Builder (99 €/mes) o superior. Las claves Free y Hobby reciben 403 plan_lacks_ws en /v1/stream; consulta los precios.
Paso 3 — El bot#
Guárdalo como discord-picks-bot.mjs:
// discord-picks-bot.mjs: post Pinnacle steam moves to a Discord channel through an incoming webhook.
// npm install ws Node 18+ (global fetch)
// env: PINNFEED_KEY (Builder or higher), DISCORD_WEBHOOK (channel webhook URL; unset = dry run)
import WebSocket from 'ws';
const WS_URL = process.env.PINNFEED_WS || 'wss://pinnfeed.io/v1/stream';
const { PINNFEED_KEY, DISCORD_WEBHOOK } = process.env;
const MIN_DROP = Number(process.env.MIN_DROP || 0.05); // 5% shortening...
const WINDOW_MS = Number(process.env.WINDOW_MS || 120000); // ...inside 2 minutes
const MIN_STAKE = Number(process.env.MIN_STAKE || 100); // skip thin markets
const SIDES = new Set(['home', 'away', 'draw', 'over', 'under']);
const events = new Map();
const highs = new Map(); // "marketId|outcome" -> [{t, p}]
const sent = new Map(); // "marketId|outcome" -> last alert time
const queue = []; // embeds waiting to be posted
let busy = false;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// One worker drains the queue, so a slow or rate-limited webhook never blocks the stream.
async function drain() {
if (busy) return;
busy = true;
while (queue.length) {
const embed = queue.shift();
if (!DISCORD_WEBHOOK) { console.log('[dry run]', embed.title, '|', embed.description.replace(/\n/g, ' | ')); continue; }
for (let attempt = 0; attempt < 3; attempt++) {
const r = await fetch(DISCORD_WEBHOOK, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ embeds: [embed], allowed_mentions: { parse: [] } }),
}).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
if (r.status === 429) { const b = await r.json().catch(() => ({})); await sleep(1000 * (b.retry_after ?? 1)); continue; }
if (!r.ok) console.error('discord', r.status, await r.text());
break;
}
}
busy = false;
}
function post(embed) {
queue.push(embed);
if (queue.length > 50) queue.shift(); // an alert from minutes ago is no longer news
drain();
}
function check(m) {
const ev = events.get(m.eventId);
if (!ev || (m.maxStake ?? 0) < MIN_STAKE) return;
const now = Date.now();
for (const o of m.outcomes) {
if (!SIDES.has(o.name)) continue;
const k = `${m.id}|${o.name}`;
const h = (highs.get(k) || []).filter((x) => now - x.t < WINDOW_MS);
h.push({ t: now, p: o.decimal });
highs.set(k, h);
const high = Math.max(...h.map((x) => x.p));
const drop = 1 - o.decimal / high;
if (drop < MIN_DROP || now - (sent.get(k) || 0) < WINDOW_MS) continue;
sent.set(k, now);
const pick = o.name === 'home' ? ev.home : o.name === 'away' ? ev.away : o.name;
const line = o.points == null ? '' : m.type === 'total' ? ` ${o.points}` : ` ${o.points > 0 ? '+' : ''}${o.points}`;
post({
title: `📉 ${ev.home} v ${ev.away}`,
description: `**${pick}** (${m.type}${line})\nPinnacle ${high.toFixed(2)} → ${o.decimal.toFixed(2)} (−${(drop * 100).toFixed(1)}%)\n` +
`Fair now ${o.fair.toFixed(2)} · max stake ${m.maxStake}`,
color: 0xe8590c,
footer: { text: `${ev.sport.toUpperCase()} · ${ev.league?.name ?? ''} · 18+ · not a tip` },
timestamp: new Date(now).toISOString(),
});
}
}
function connect(delay = 1000) {
const ws = new WebSocket(WS_URL, { headers: { 'x-api-key': PINNFEED_KEY }, perMessageDeflate: true });
ws.on('open', () => {
delay = 1000;
ws.send(JSON.stringify({ type: 'subscribe', sports: ['soccer', 'tennis', 'basketball'], markets: ['moneyline', 'spread', 'total'], main: true, specials: false }));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'ping') return ws.send('{"type":"pong"}');
if (msg.type === 'snapshot') msg.events.forEach((e) => events.set(e.id, e));
if (msg.type === 'delta') for (const c of msg.changes) {
if (c.kind === 'event') c.op === 'upsert' ? events.set(c.event.id, c.event) : events.delete(c.id);
if (c.kind === 'market' && c.op === 'upsert') check(c.market);
}
});
ws.on('close', () => setTimeout(() => connect(Math.min(delay * 2, 30000)), delay));
ws.on('error', (e) => console.error('ws error:', e.message));
}
connect();
Qué hace, en orden:
- Se suscribe a las líneas principales (moneyline, hándicap, total) de fútbol, tenis y baloncesto. Cambia
sportspor cualquiera de los 13 deportes de tu plan; el protocolo WebSocket (en inglés) lista todos los filtros. - Guarda una ventana de 2 minutos por mercado y resultado, y marca una caída de
MIN_DROPrespecto al máximo de la ventana. Los mercados por debajo deMIN_STAKEse ignoran, porque un movimiento con un límite bajo dice poco. - Encola la publicación. Un único proceso envía los embeds al webhook, así un webhook lento o limitado nunca bloquea el stream. Si hay más de 50 alertas esperando, se descarta la más antigua.
- Respeta los 429. Discord responde a las ráfagas con
429y un valorretry_afteren segundos en el cuerpo JSON. El bot espera ese tiempo y reintenta, hasta tres intentos. - Sin menciones.
allowed_mentions: { parse: [] }impide que un nombre de equipo o jugador mencione a alguien en el canal.
Paso 4 — Ejecútalo y pruébalo#
node discord-picks-bot.mjs
En modo de prueba, una alerta se imprime así:
[dry run] 📉 Udinese v Fiorentina | **Udinese** (spread +0.25) | Pinnacle 1.88 → 1.70 (−9.6%) | Fair now 1.75 · max stake 150
En Discord la misma alerta es un embed: el partido como título, el lado y el movimiento de cuota en el cuerpo, la cuota justa sin margen y la apuesta máxima en la última línea, y deporte, liga y la nota «18+ · not a tip» en el pie.
Ajusta el volumen con variables de entorno; por ejemplo, MIN_DROP=0.08 para menos alertas y más fuertes, o MIN_STAKE=500 para quedarte solo con mercados más profundos.
Paso 5 — Mantenlo en marcha#
- Ejecútalo con
systemd,pm2o un contenedor que se reinicie si falla. El bot se reconecta solo al WebSocket con backoff y reconstruye su lista de eventos desde el nuevo snapshot. - Basta un webhook por canal. Si quieres fútbol y tenis en canales distintos, ejecuta dos copias con otros
sportsy webhooks; una clave Builder permite 3 conexiones WebSocket. - Lleva el historial con honestidad: registra cada alerta y mídela con el valor de la línea de cierre, no con el porcentaje de aciertos.
- Para ver qué publicar y cómo moderar un servidor, consulta el caso de uso de servidores de Discord.
Preguntas frecuentes#
¿Qué plan de PinnFeed necesita el bot de Discord?#
Builder (99 €/mes) o superior, porque lee el WebSocket. Mira la página del precio del WebSocket en vivo + prepartido para comparar. Los planes de pago tienen 3 días de prueba gratis; se pide tarjeta y se cobra tras el día 3 salvo que canceles.
¿Puedo usarlo en el plan Free?#
Tal cual, no. Free es solo REST con 30 segundos de retraso. Para un bot más lento en Free o Hobby, haz polling a /v1/drops una vez por minuto y publica el resultado en el mismo webhook; consulta la documentación REST (en inglés).
¿Necesito una cuenta de bot de Discord o discord.js?#
No. Un webhook entrante acepta un POST JSON simple, así que el bot solo necesita fetch. Usa una cuenta de bot con discord.js solo si quieres comandos de barra o respuestas.
¿Cómo evito saturar el canal?#
Sube MIN_DROP o MIN_STAKE, reduce sports y mantén el enfriamiento por resultado (una alerta por lado de mercado cada WINDOW_MS). El límite de la cola descarta alertas viejas en lugar de publicarlas tarde.
¿Es legal publicar picks en un servidor de Discord?#
Las normas sobre pronósticos y publicidad del juego cambian según el país (en España, por ejemplo, la publicidad del juego está regulada), y Discord tiene sus propias normas de comunidad. Revisa las normas de donde estéis tú y tus miembros, mantén el servidor solo para mayores de 18 años y nunca presentes las alertas como ganancias seguras.