Crie um bot de palpites para Discord com odds da Pinnacle ao vivo
Este bot acompanha os preços da Pinnacle no WebSocket da PinnFeed e publica um embed num canal do Discord sempre que um mercado tem steam: a odd cai 5% ou mais em dois minutos num mercado com aposta máxima de pelo menos 100. É um único arquivo de umas 90 linhas. Não precisa de biblioteca do Discord nem de conta de bot, porque publica por um webhook de entrada do canal.
É o gêmeo no Discord do bot de palpites para Telegram. A regra de detecção é a explicada no guia de detecção de steam. É uma ferramenta educacional para maiores de 18 anos, não palpites garantidos.
Passo 1 — Crie o webhook#
- No seu servidor do Discord, abra Configurações do servidor → Integrações → Webhooks e escolha Novo webhook.
- Dê um nome, escolha o canal onde ele vai publicar e use Copiar URL do webhook.
- Trate essa URL como uma senha: quem tiver a URL pode publicar no canal. Se vazar, apague o webhook e crie outro.
Passo 2 — Configure o ambiente#
export PINNFEED_KEY="ps_live_..." # plano Builder (WebSocket)
export DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." # do passo 1
npm install ws
Se deixar DISCORD_WEBHOOK sem valor, o bot roda em modo de teste: imprime cada alerta em vez de publicar.
O stream exige Builder (€99/mês) ou superior. Chaves Free e Hobby recebem 403 plan_lacks_ws em /v1/stream; veja os preços.
Passo 3 — O bot#
Salve 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();
O que ele faz, em ordem:
- Assina as linhas principais (moneyline, handicap, total) de futebol, tênis e basquete. Troque
sportspor qualquer um dos 13 esportes do seu plano; o protocolo WebSocket (em inglês) lista todos os filtros. - Guarda uma janela de 2 minutos por mercado e resultado e marca uma queda de
MIN_DROPem relação ao máximo da janela. Mercados abaixo deMIN_STAKEsão ignorados, porque um movimento com limite baixo diz pouco. - Enfileira a publicação. Um único worker envia os embeds ao webhook, então um webhook lento ou limitado nunca trava o stream. Se houver mais de 50 alertas esperando, o mais antigo é descartado.
- Respeita os 429. O Discord responde a rajadas com
429e um valorretry_afterem segundos no corpo JSON. O bot espera esse tempo e tenta de novo, até três tentativas. - Sem menções.
allowed_mentions: { parse: [] }impede que o nome de um time ou jogador mencione alguém no canal.
Passo 4 — Rode e teste#
node discord-picks-bot.mjs
No modo de teste, um alerta aparece assim:
[dry run] 📉 Udinese v Fiorentina | **Udinese** (spread +0.25) | Pinnacle 1.88 → 1.70 (−9.6%) | Fair now 1.75 · max stake 150
No Discord o mesmo alerta vira um embed: a partida como título, o lado e o movimento da odd no corpo, a odd justa sem margem e a aposta máxima na última linha, e esporte, liga e a nota "18+ · not a tip" no rodapé.
Ajuste o volume com variáveis de ambiente, por exemplo MIN_DROP=0.08 para menos alertas e mais fortes, ou MIN_STAKE=500 para ficar só com mercados mais profundos.
Passo 5 — Mantenha rodando#
- Rode com
systemd,pm2ou um contêiner que reinicie em caso de falha. O bot se reconecta sozinho ao WebSocket com backoff e reconstrói a lista de eventos a partir do novo snapshot. - Um webhook por canal basta. Se quiser futebol e tênis em canais separados, rode duas cópias com outros
sportse webhooks; uma chave Builder permite 3 conexões WebSocket. - Registre o histórico com honestidade: anote cada alerta e meça com o valor da linha de fechamento, não com a taxa de acerto.
- Para saber o que publicar e como moderar um servidor, veja o caso de uso de servidores de Discord.
Perguntas frequentes#
Qual plano da PinnFeed o bot de Discord precisa?#
Builder (€99/mês) ou superior, porque lê o WebSocket. Veja a página do preço do WebSocket ao vivo + pré-jogo para comparar. Os planos pagos têm 3 dias de teste grátis; o cartão é obrigatório e a cobrança acontece após o dia 3, a menos que você cancele.
Posso rodar no plano Free?#
Do jeito que está, não. O Free é só REST com 30 segundos de atraso. Para um bot mais lento no Free ou no Hobby, consulte /v1/drops uma vez por minuto e publique o resultado no mesmo webhook; veja a documentação REST (em inglês).
Preciso de uma conta de bot do Discord ou do discord.js?#
Não. Um webhook de entrada aceita um POST JSON simples, então o bot só precisa de fetch. Use uma conta de bot com discord.js só se quiser comandos de barra ou respostas.
Como evito lotar o canal?#
Aumente MIN_DROP ou MIN_STAKE, reduza sports e mantenha o intervalo por resultado (um alerta por lado de mercado a cada WINDOW_MS). O limite da fila descarta alertas velhos em vez de publicá-los atrasados.
É legal publicar palpites num servidor do Discord?#
As regras sobre palpites e publicidade de apostas mudam de país para país (no Brasil, por exemplo, as apostas de quota fixa e a publicidade delas são regulamentadas), e o Discord tem suas próprias diretrizes da comunidade. Confira as regras de onde você e seus membros estão, mantenha o servidor só para maiores de 18 anos e nunca apresente alertas como ganhos garantidos.