Crea un bot di pronostici per Discord con le quote Pinnacle live
Questo bot segue i prezzi di Pinnacle sul WebSocket di PinnFeed e pubblica un embed in un canale Discord ogni volta che un mercato fa steam: la quota scende del 5% o più in due minuti in un mercato con una puntata massima di almeno 100. È un solo file di circa 90 righe. Non serve una libreria Discord né un account bot, perché pubblica tramite un webhook in entrata del canale.
È il gemello su Discord del bot di pronostici per Telegram. La regola di rilevamento è quella spiegata nella guida al rilevamento dello steam. È uno strumento didattico per maggiori di 18 anni, non pronostici.
Passo 1 — Crea il webhook#
- Nel tuo server Discord apri Impostazioni server → Integrazioni → Webhook e scegli Nuovo webhook.
- Dagli un nome, scegli il canale in cui pubblicherà e usa Copia URL webhook.
- Tratta quell'URL come una password: chi ce l'ha può pubblicare nel canale. Se trapela, elimina il webhook e creane uno nuovo.
Passo 2 — Configura l'ambiente#
export PINNFEED_KEY="ps_live_..." # piano Builder (WebSocket)
export DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." # dal passo 1
npm install ws
Se lasci DISCORD_WEBHOOK vuota, il bot gira in modalità di prova: stampa ogni avviso invece di pubblicarlo.
Lo stream richiede Builder (99 €/mese) o superiore. Le chiavi Free e Hobby ricevono 403 plan_lacks_ws su /v1/stream; vedi i prezzi.
Passo 3 — Il bot#
Salvalo come 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();
Cosa fa, in ordine:
- Si iscrive alle linee principali (moneyline, spread, total) di calcio, tennis e basket. Cambia
sportscon uno qualsiasi dei 13 sport del tuo piano; il protocollo WebSocket (in inglese) elenca tutti i filtri. - Tiene una finestra di 2 minuti per mercato ed esito e segnala un calo di
MIN_DROPrispetto al massimo della finestra. I mercati sottoMIN_STAKEvengono saltati, perché un movimento con un limite basso dice poco. - Mette in coda la pubblicazione. Un solo worker invia gli embed al webhook, così un webhook lento o limitato non blocca mai lo stream. Se più di 50 avvisi sono in attesa, il più vecchio viene scartato.
- Rispetta i 429. Discord risponde alle raffiche con
429e un valoreretry_afterin secondi nel corpo JSON. Il bot aspetta quel tempo e riprova, fino a tre tentativi. - Nessuna menzione.
allowed_mentions: { parse: [] }impedisce che il nome di una squadra o di un giocatore menzioni qualcuno nel canale.
Passo 4 — Avvialo e provalo#
node discord-picks-bot.mjs
In modalità di prova un avviso viene stampato così:
[dry run] 📉 Udinese v Fiorentina | **Udinese** (spread +0.25) | Pinnacle 1.88 → 1.70 (−9.6%) | Fair now 1.75 · max stake 150
Su Discord lo stesso avviso è un embed: la partita come titolo, il lato e il movimento di quota nel corpo, la quota equa senza margine e la puntata massima nell'ultima riga, e sport, campionato e la nota "18+ · not a tip" nel piè di pagina.
Regola il volume con variabili d'ambiente, per esempio MIN_DROP=0.08 per meno avvisi e più forti, oppure MIN_STAKE=500 per tenere solo i mercati più profondi.
Passo 5 — Tienilo acceso#
- Eseguilo con
systemd,pm2o un container con riavvio in caso di errore. Il bot si ricollega da solo al WebSocket con backoff e ricostruisce l'elenco degli eventi dal nuovo snapshot. - Basta un webhook per canale. Se vuoi calcio e tennis in canali separati, avvia due copie con altri
sportse webhook; una chiave Builder consente 3 connessioni WebSocket. - Tieni lo storico in modo onesto: registra ogni avviso e misuralo con il closing line value, non con la percentuale di vincite.
- Per cosa pubblicare e come moderare un server, vedi il caso d'uso dei server Discord (in inglese).
Domande frequenti#
Quale piano PinnFeed serve al bot Discord?#
Builder (99 €/mese) o superiore, perché legge il WebSocket. Per un confronto vedi la pagina sul prezzo del WebSocket live e pre-match. I piani a pagamento hanno 3 giorni di prova gratuita; serve una carta, addebitata dopo il giorno 3 se non annulli.
Posso usarlo con il piano Free?#
Così com'è, no. Free è solo REST con 30 secondi di ritardo. Per un bot più lento su Free o Hobby, interroga /v1/drops una volta al minuto e pubblica il risultato sullo stesso webhook; vedi la documentazione REST (in inglese).
Mi serve un account bot Discord o discord.js?#
No. Un webhook in entrata accetta un semplice POST JSON, quindi al bot basta fetch. Usa un account bot con discord.js solo se vuoi comandi slash o risposte.
Come evito di intasare il canale?#
Alza MIN_DROP o MIN_STAKE, riduci sports e mantieni la pausa per esito (un avviso per lato di mercato ogni WINDOW_MS). Il limite della coda scarta gli avvisi vecchi invece di pubblicarli in ritardo.
È consentito pubblicare pronostici in un server Discord?#
Le regole su pronostici e pubblicità del gioco cambiano da paese a paese; in Italia, per esempio, il Decreto Dignità (2018) vieta la pubblicità del gioco con vincite in denaro. Anche Discord ha le sue linee guida della community. Verifica le regole dove ti trovi tu e dove si trovano i tuoi membri, riserva il server ai maggiori di 18 anni e non presentare mai gli avvisi come vincite sicure. Questa non è una consulenza legale.