From 1fe970171d86198a2dee8bd7d0e089c19110cf17 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 24 May 2026 20:25:32 +0000 Subject: [PATCH] Initial commit: Padel Planner Telegram bot - Telegram bot (bot.js) voor beschikbaarheid, matchen en scores - Padel Peakz integratie (padel-peakz.js) - CHANGELOG.md en .gitignore --- .gitignore | 5 + CHANGELOG.md | 10 + bot-live.js | 1707 ++++++++++++++++++++++++++++++++++++++++++++++++ bot.js | 1084 ++++++++++++++++++++++++++++++ padel-peakz.js | 97 +++ 5 files changed, 2903 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 bot-live.js create mode 100644 bot.js create mode 100644 padel-peakz.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e78bd86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +*.log +npm-debug.log* +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2d8f9d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# CHANGELOG + +## 1.0.0 (2026-05-24) + +- Eerste commit — live Telegram bot voor Padel Planner +- `/nu` commando voor snelle beschikbaarheid +- `/beschikbaar` menu voor vastleggen wekelijkse/eenmalige beschikbaarheid +- `/match` voor automatisch matchen +- `/score` voor wedstrijd uitslagen +- `/start` registratie met PIN diff --git a/bot-live.js b/bot-live.js new file mode 100644 index 0000000..379c418 --- /dev/null +++ b/bot-live.js @@ -0,0 +1,1707 @@ +const TOKEN = process.env.BOT_TOKEN; +if (!TOKEN) { + console.error('[PADEL-BOT][FATAL] BOT_TOKEN not set in environment'); + process.exit(1); +} +const API_URL = process.env.API_URL || 'http://nova-padel-api:3000'; +const PORT = process.env.PORT || 3001; +const GROUP_CHAT_ID = process.env.GROUP_CHAT_ID || null; + +// ─── Simple logger (visible in docker logs) ─── +function log(...args) { + const prefix = new Date().toISOString() + ' [PADEL-BOT]'; + process.stdout.write(prefix + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + '\n'); +} + +function logError(...args) { + const prefix = new Date().toISOString() + ' [PADEL-BOT][ERROR]'; + process.stderr.write(prefix + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + '\n'); +} + +// ─── Dependencies ─── +const TelegramBot = require('node-telegram-bot-api'); +const http = require('http'); + +// ─── Bot init ─── +const bot = new TelegramBot(TOKEN, { polling: true }); + +// ─── Helpers ─── +function api(path, method = 'GET', body = null) { + return new Promise((resolve, reject) => { + const url = new URL(API_URL + '/api' + path); + const opts = { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(opts, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + log('API response', path, res.statusCode, data.slice(0, 100)); + try { + const parsed = JSON.parse(data); + if (Array.isArray(parsed)) { + resolve(parsed); + } else if (parsed && typeof parsed === 'object' && path === '/players') { + resolve(parsed.players || []); + } else { + resolve(parsed); + } + } catch { + reject(new Error('Invalid JSON: ' + data.slice(0, 100))); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +function daysShort() { + const d = ['zo','ma','di','wo','do','vr','za']; + const today = new Date().getDay(); + return [...d.slice(today), ...d.slice(0, today)]; +} + +function daysNL() { + return ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag']; +} + +async function getPlayerNames(playerIds) { + try { + const players = await api('/players'); + const arr = Array.isArray(players) ? players : []; + return playerIds.map(id => { + const p = arr.find(pl => pl.id === id); + return p ? p.name : 'Onbekend'; + }); + } catch { + return playerIds.map(() => '?'); + } +} + +// ─── Rate limiter ─── +// Per-chat: max 1 msg/sec, per-group: max 1 per 3 sec, global: max 30/sec +const msgQueue = []; +let lastPerChat = {}; +let lastGroupMsg = 0; +let globalTick = 0; +const GROUP_COOLDOWN_MS = 3000; // 20/min = 1 per 3 sec + +function throttledSend(chatId, text, opts) { + const now = Date.now(); + const last = lastPerChat[chatId] || 0; + const delay = Math.max( + last ? last + 1100 - now : 0, // per-chat: 1/sec + chatId < 0 ? GROUP_COOLDOWN_MS - (now - lastGroupMsg) : 0, // groep: 1 per 3 sec + globalTick * 50 // global: ~20/sec + ); + + if (delay > 0) { + msgQueue.push({ chatId, text, opts, at: now + delay }); + if (globalTick < 20) globalTick++; + return Promise.resolve(); + } + + lastPerChat[chatId] = now; + if (chatId < 0) lastGroupMsg = now; + return sendRaw(chatId, text, opts); +} + +// Verwerk queue elke 200ms +setInterval(() => { + const now = Date.now(); + while (msgQueue.length > 0 && msgQueue[0].at <= now) { + const item = msgQueue.shift(); + lastPerChat[item.chatId] = now; + if (item.chatId < 0) lastGroupMsg = now; + sendRaw(item.chatId, item.text, item.opts); + } +}, 200); + +function sendRaw(chatId, text, opts) { + return bot.sendMessage(chatId, text, opts).catch(err => { + // 429 = rate limit — retry after retry_after + if (err.response && err.response.statusCode === 429) { + const retryAfter = (err.response.body && err.response.body.parameters && err.response.body.parameters.retry_after) || 3; + log('Rate limited, retrying in', retryAfter, 's'); + msgQueue.push({ chatId, text, opts, at: Date.now() + retryAfter * 1000 }); + } else { + logError('sendMessage failed for chat', chatId, ':', err.message); + } + }); +} + +// ─── State per user ─── +const userState = new Map(); +// TTL: verwijder state na 30 min inactiviteit +const USER_STATE_TTL_MS = 30 * 60 * 1000; +setInterval(() => { + const now = Date.now(); + for (const [chatId, state] of userState) { + if (state._ts && (now - state._ts) > USER_STATE_TTL_MS) { + userState.delete(chatId); + } + } +}, 60 * 1000); // elke minuut opruimen + +// Helper om state te setten met timestamp +function setUserState(chatId, data) { + data._ts = Date.now(); + userState.set(chatId, data); +} + +// ─── Safe sendMessage wrapper (met rate limiting) ─── +const sendMsg = throttledSend; + +// ─── Commands ─── + +// /start — registratie +bot.onText(/\/start(@padel_nl_bot)?/, async (msg) => { + const chatId = msg.chat.id; + const chatType = msg.chat.type; + const name = msg.from.first_name || 'Speler'; + + // In groep: redirect naar DM + if (chatType === 'group' || chatType === 'supergroup') { + return sendMsg(chatId, + '🏓 **Registreren werkt alleen in DM.**\n\nStuur /start naar [@padel_nl_bot](https://t.me/padel_nl_bot)', + { parse_mode: 'Markdown' } + ); + } + + // Check of al geregistreerd + try { + const players = await api('/players'); + if (!Array.isArray(players)) { + throw new Error('Invalid response from API'); + } + const existing = players.find(p => p.telegram_id == chatId); + if (existing) { + return sendMsg(chatId, + 'Welkom terug, ' + existing.name + '! 🏓\n\nJe PIN: ' + existing.pin + '\n\n🌐 Ga naar de webpagina om beschikbaarheid in te stellen:\nhttps://padel.iamdoingthings.com\n\nGroep: /wie — wie is er geregistreerd'); + } + } catch (e) { + logError('/start check error:', e.message); + } + + // Start registratie + setUserState(chatId, { step: 'register_name', data: { telegram_id: chatId } }); + sendMsg(chatId, 'Hallo ' + name + '! 🏓\n\nWat is je volledige naam?'); +}); + +// /beschikbaar, /mijn — redirect naar web +bot.onText(/\/beschikbaar(@padel_nl_bot)?|\/mijn(@padel_nl_bot)?/, async (msg) => { + const chatId = msg.chat.id; + sendMsg(chatId, '🌐 **Ga naar de webpagina** voor beschikbaarheid en profiel:\nhttps://padel.iamdoingthings.com\n\nLog in met je PIN (die je kreeg bij registratie).', + { parse_mode: 'Markdown', disable_web_page_preview: true } + ); +}); + +// /matches — openstaande matches +bot.onText(/\/matches(@padel_nl_bot)?/, async (msg) => { + const chatId = msg.chat.id; + try { + const players = await api('/players'); + if (!Array.isArray(players)) { + throw new Error('Invalid response from API'); + } + const player = players.find(p => p.telegram_id == chatId); + if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start'); + + const matches = await api('/matches'); + if (!Array.isArray(matches)) { + throw new Error('Invalid matches response'); + } + const myMatches = matches.filter(m => m.players && m.players.some(pid => pid == player.id) && m.status === 'proposed'); + + if (myMatches.length === 0) { + return sendMsg(chatId, 'Geen openstaande matches.'); + } + + for (const m of myMatches) { + const names = (m.players || []).map(id => { + const p = players.find(pl => pl.id === id); + return p ? p.name : 'Onbekend'; + }); + const resp = m.responses?.[player.id] || 'pending'; + + const keyboard = [ + [{ text: '✅ Accepteren', callback_data: 'match_accept_' + m.id }, + { text: '❌ Negeren', callback_data: 'match_reject_' + m.id }] + ]; + + await sendMsg(chatId, + '**' + m.day + ' ' + m.date + '** — ' + m.start + '-' + m.end + '\n' + names.join(', ') + '\n\nJouw status: ' + resp, + { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } }); + } + } catch (e) { + logError('/matches error:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } +}); + +// /ping — health check +bot.onText(/\/ping(@padel_nl_bot)?/, (msg) => { + sendMsg(msg.chat.id, '🏓 Pong! Bot is live.'); +}); + +// ─── /feedback — stuur feedback naar beheerder ─── +const OWNER_TELEGRAM_ID = 1231195086; // Melvin + +bot.onText(/\/feedback(@padel_nl_bot)?(\s+(.+))?/, async (msg, match) => { + const chatId = msg.chat.id; + const text = match ? match[3] : null; + + if (!text || !text.trim()) { + // Geen tekst meegegeven — vraag om feedback + return sendMsg(chatId, + '📝 **Feedback**\n\n' + + 'Heb je een idee, bug of suggestie? Stuur het direct naar de beheerder!\n\n' + + 'Gebruik: /feedback [je bericht]\n' + + 'Voorbeeld: /feedback Het werkt top, maar ik mis een donker thema', + { parse_mode: 'Markdown' } + ); + } + + const feedback = text.trim(); + const senderName = msg.from.first_name || 'Onbekend'; + const senderUsername = msg.from.username ? '@' + msg.from.username : ''; + + // Stuur naar de beheerder + const fbMsg = '📬 **Nieuwe feedback**\n\n' + + 'Van: ' + senderName + ' ' + senderUsername + '\n' + + 'Chat: ' + chatId + '\n\n' + + '---\n' + + feedback; + + await sendMsg(OWNER_TELEGRAM_ID, fbMsg, { parse_mode: 'Markdown' }); + + // Bevestig naar de verzender + await sendMsg(chatId, '✅ Feedback ontvangen! Bedankt voor het meedenken.'); + + log('Feedback van', senderName, chatId, ':', feedback.slice(0, 100)); +}); + +// /setgroup — stel notificatie groep in +bot.onText(/\/setgroup(@padel_nl_bot)?/, async (msg) => { + if (msg.chat.type === 'group' || msg.chat.type === 'supergroup') { + process.env.GROUP_CHAT_ID = String(msg.chat.id); + sendMsg(msg.chat.id, '✅ Deze groep is ingesteld voor match notificaties!'); + } else { + sendMsg(msg.chat.id, 'Voeg me eerst toe aan een groep en stuur /setgroup daar.'); + } +}); + +// /wie — who is registered +bot.onText(/\/wie(@padel_nl_bot)?/, async (msg) => { + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid response'); + const list = players.map(p => '• ' + p.name + ' (level ' + (p.level || '?') + ', ' + (p.position || '?') + ')').join('\n'); + sendMsg(msg.chat.id, '🏓 Geregistreerde spelers (' + players.length + '):\n\n' + list); + } catch (e) { + sendMsg(msg.chat.id, 'Fout: ' + e.message); + } +}); + +// --- /nu --- supersnel beschikbaar doorgeven --- +// /nu = nu, /nu 18:00, /nu di, /nu di 18:00 +bot.onText(/\/nu(@padel_nl_bot)?(\s+(.+))?/, async (msg, match) => { + const chatId = msg.chat.id; + const rest = match ? match[3] : null; + + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players'); + const player = players.find(p => p.telegram_id == chatId); + if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start'); + + const today = new Date(); + const dayNames = ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag']; + const dagMap = { ma:1, di:2, wo:3, do:4, vr:5, za:6, zo:0, maandag:1, dinsdag:2, woensdag:3, donderdag:4, vrijdag:5, zaterdag:6, zondag:0 }; + + let targetDay = dayNames[today.getDay()]; + let targetStart = null; + + if (rest && rest.trim()) { + const parts = rest.trim().split(/\s+/); + const first = parts[0].toLowerCase(); + if (dagMap[first] !== undefined) { + const diff = (dagMap[first] - today.getDay() + 7) % 7; + const d = new Date(today); + d.setDate(today.getDate() + diff); + targetDay = dayNames[d.getDay()]; + if (parts[1]) targetStart = parts[1]; + } else if (parts[0].match(/^\d{2}:\d{2}$/)) { + targetStart = parts[0]; + } + } + + // Bepaal starttijd + if (!targetStart) { + const now = today.getHours() * 60 + today.getMinutes(); + let nextSlot = Math.ceil(now / 30) * 30; + targetStart = String(Math.floor(nextSlot / 60)).padStart(2,'0') + ':' + String(nextSlot % 60).padStart(2,'0'); + if (nextSlot >= 23*60) { + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + targetDay = dayNames[tomorrow.getDay()]; + targetStart = '08:00'; + } + } + + if (!targetStart || !targetStart.match(/^\d{2}:\d{2}$/)) { + return sendMsg(chatId, 'Gebruik: /nu, /nu 18:00, /nu di, /nu di 18:00'); + } + + const [sh, sm] = targetStart.split(':').map(Number); + const totalMin = sh * 60 + sm + 90; + let targetEnd = String(Math.floor(totalMin / 60) % 24).padStart(2,'0') + ':' + String(totalMin % 60).padStart(2,'0'); + + // Als eindtijd over middernacht gaat, dag doorschuiven + let endDay = targetDay; + if (totalMin >= 24 * 60) { + const dayNames = ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag']; + const nextIdx = (dayNames.indexOf(targetDay) + 1) % 7; + endDay = dayNames[nextIdx]; + targetEnd = String(Math.floor(totalMin / 60) % 24).padStart(2,'0') + ':' + String(totalMin % 60).padStart(2,'0'); + } + + const avail = player.availability || {}; + if (!avail[targetDay]) avail[targetDay] = []; + const existingIdx = avail[targetDay].findIndex(s => s.start === targetStart); + if (existingIdx >= 0) { + return sendMsg(chatId, 'Je staat al geregistreerd voor ' + targetDay + ' ' + targetStart + '-' + targetEnd); + } + avail[targetDay].push({ start: targetStart, end: targetEnd, duration: 90 }); + + await api('PUT', '/players/' + player.id + '/availability', { availability: avail, avail_mode: player.avail_mode || 'flex' }); + + const otherPlayers = players.filter(p => p.id !== player.id && p.telegram_id); + const alsoAvail = otherPlayers.filter(p => { + const pa = p.availability || {}; + const pSlots = pa[targetDay] || []; + return pSlots.some(s => s.start === targetStart); + }); + + let msg = '\uD83D\uDFE2 **Je staat genoteerd!**\n'; + if (endDay !== targetDay) { + msg += '\uD83D\uDCC5 ' + targetDay + ' ' + targetStart + ' - ' + endDay + ' ' + targetEnd + '\n\n'; + } else { + msg += '\uD83D\uDCC5 ' + targetDay + ' ' + targetStart + '-' + targetEnd + '\n\n'; + } + if (alsoAvail.length > 0) { + msg += 'Ook beschikbaar:\n' + alsoAvail.map(p => '\u2022 ' + p.name).join('\n'); + msg += '\n\nStuur /match om een match te zoeken!'; + } else { + msg += 'Nog niemand anders beschikbaar op deze tijd.'; + } + + await sendMsg(chatId, msg, { parse_mode: 'Markdown' }); + } catch (e) { + logError('/nu error:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } +}); + +// --- /beschikbaar --- beschikbaarheid beheren --- +bot.onText(/\/beschikbaar(@padel_nl_bot)?/, async (msg) => { + const chatId = msg.chat.id; + + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players'); + const player = players.find(p => p.telegram_id == chatId); + if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start'); + + const avail = player.availability || {}; + const dayNames = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag']; + const dayShort = ['ma','di','wo','do','vr','za','zo']; + + const hasAvail = dayNames.filter(d => avail[d] && avail[d].length > 0); + + let msg = '\uD83D\uDCC5 **Jouw beschikbaarheid**\n\n'; + if (hasAvail.length === 0) { + msg += 'Nog geen beschikbaarheid ingesteld.\n'; + } else { + for (const d of hasAvail) { + const times = avail[d].map(s => s.start + '-' + s.end).join(', '); + const idx = dayNames.indexOf(d); + msg += '\uD83D\uDFE2 ' + dayShort[idx] + ' ' + times + '\n'; + } + } + + msg += '\nWat wil je doen?'; + + const keyboard = [ + [{ text: '\u2795 Vaste dag', callback_data: 'avail_add_weekly' }], + [{ text: '\uD83D\uDCC5 Eenmalig', callback_data: 'avail_add_once' }], + [{ text: '\uD83D\uDC40 Mijn overzicht', callback_data: 'avail_view' }], + [{ text: '\u274C Wissen', callback_data: 'avail_clear' }], + ]; + + await sendMsg(chatId, msg, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } }); + } catch (e) { + logError('/beschikbaar error:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } +}); + + +// /match — zoek een match +bot.onText(/\/match(@padel_nl_bot)?/, async (msg) => { + const targetChat = msg.chat.id; + try { + const result = await runMatching(); + if (!result) { + sendMsg(targetChat, 'Geen match gevonden voor de komende 7 dagen.'); + return; + } + const names = result.players.map(p => p.name).join(', '); + const msgText = '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌'; + const opts = { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id }, + { text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }] + ] + } + }; + + sendMsg(targetChat, msgText, opts); + + for (const p of result.players) { + if (p.telegram_id) { + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer: /matches', + { parse_mode: 'Markdown' } + ); + } + } + } catch (e) { + sendMsg(targetChat, 'Fout: ' + e.message); + } +}); + +// ─── Score state ─── +// Per chatId: { step: 'awaiting_score'|'awaiting_teammate'|'awaiting_verification', matchId, submitted_by, team1, team2, sets, scoreStr } + +// ─── /score — voer score in (DM only) ─── +bot.onText(/\/score(@padel_nl_bot)?(\s+(.+))?/, async (msg, regexMatch) => { + const chatId = msg.chat.id; + const chatType = msg.chat.type; + + // Alleen in DM + if (chatType !== 'private') { + return sendMsg(chatId, '📝 Score invoeren alleen in DM. Stuur het commando naar @padel_nl_bot.'); + } + + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players response'); + const player = players.find(p => p.telegram_id == chatId); + if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start'); + + // Vind completed/confirmed matches waar deze speler in zit + const matches = await api('/matches'); + if (!Array.isArray(matches)) throw new Error('Invalid matches response'); + + // Filter: matches waar speler in zit, status=confirmed of completed, nog geen confirmed score + const openMatches = matches.filter(m => + m.players && m.players.some(pid => pid == player.id) && + (m.status === 'confirmed' || m.status === 'completed') && + (!m.score || m.score.status !== 'confirmed') + ); + + if (openMatches.length === 0) { + return sendMsg(chatId, 'Geen openstaande scores om in te vullen. Je hebt geen recente wedstrijden zonder score.'); + } + + // Als er meerdere zijn, laat kiezen + if (openMatches.length > 1) { + const keyboard = openMatches.map(m => { + const names = (m.players || []).map(pid => { + const p = players.find(pl => pl.id === pid); + return p ? p.name : '?'; + }).join(', '); + return [{ text: m.date + ' ' + m.start + ' — ' + names, callback_data: 'score_match_' + m.id }]; + }); + return sendMsg(chatId, 'Voor welke wedstrijd wil je de score invullen?', { + reply_markup: { inline_keyboard: keyboard } + }); + } + + // Eén match — ga direct naar score invoer + const selectedMatch = openMatches[0]; + + // Zit er al een score bij? Check of deze speler al heeft ingediend + if (selectedMatch.score && selectedMatch.score.status === 'pending') { + // Al ingediend, wacht op verificatie + return sendMsg(chatId, 'Er is al een score ingediend voor deze wedstrijd. Wacht tot een tegenstander verifieert.'); + } + + // regexMatch[3] = alles na /score (de inline parameters) + const rest = regexMatch ? regexMatch[3] : null; + if (rest && rest.trim()) { + // /score 6-3 6-4 [met X] + await handleScoreInput(chatId, player, selectedMatch, rest.trim(), players); + } else { + // /score zonder parameters — vraag sets + setUserState(chatId, { + step: 'awaiting_score', + matchId: selectedMatch.id, + player: player + }); + sendMsg(chatId, '📋 **Wedstrijd:** ' + (selectedMatch.date || '?') + ' ' + (selectedMatch.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5` (2 sets)\n`4-6 6-3 10-7` (3 sets / super tiebreak)', { parse_mode: 'Markdown' }); + } + } catch (e) { + logError('/score error:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } +}); + +async function handleScoreInput(chatId, player, match, input, players) { + // Parse: "6-3 6-4" of "6-3 6-4 met Thomas" + let setsStr, teammateName; + + const metMatch = input.match(/^(.+?)\s+met\s+(.+)$/i); + if (metMatch) { + setsStr = metMatch[1].trim(); + teammateName = metMatch[2].trim(); + } else { + setsStr = input.trim(); + teammateName = null; + } + + // Parse sets: "6-3 6-4" → [{team1:6, team2:3}, {team1:6, team2:4}] + const parts = setsStr.split(/[\s,]+/).filter(Boolean); + const sets = []; + const setStrParts = []; + + for (const p of parts) { + const sp = p.split('-'); + if (sp.length !== 2) { + return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik formaat zoals 6-4 7-5'); + } + const s1 = parseInt(sp[0], 10); + const s2 = parseInt(sp[1], 10); + if (isNaN(s1) || isNaN(s2)) { + return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik getallen zoals 6-4'); + } + sets.push({ team1: s1, team2: s2 }); + setStrParts.push(p); + } + + if (sets.length < 2) { + return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in. Bijvoorbeeld: 6-4 7-5. Of bij 3 sets / super tiebreak: 4-6 6-3 10-7'); + } + + const scoreStr = setStrParts.join(', '); + + // Bepaal winnaar op basis van sets + let team1Wins = 0, team2Wins = 0; + for (const s of sets) { + if (s.team1 > s.team2) team1Wins++; + else team2Wins++; + } + + if (team1Wins === team2Wins) { + return sendMsg(chatId, '⚠️ Gelijkspel in sets. Bij padel kan dat niet — wie heeft de wedstrijd gewonnen?'); + } + + const otherPlayerIds = (match.players || []).filter(pid => pid !== player.id); + const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean); + + if (teammateName) { + // Zoek teamgenoot op naam + const teammate = otherPlayers.find(p => p.name.toLowerCase() === teammateName.toLowerCase()); + if (!teammate) { + return sendMsg(chatId, '⚠️ Kan "' + teammateName + '" niet vinden in deze wedstrijd. Deelnemers: ' + otherPlayers.map(p => p.name).join(', ')); + } + + const team1 = [player.id, teammate.id]; + const team2 = otherPlayerIds.filter(pid => !team1.some(t => t == pid)); + + await submitScore(chatId, player, match, team1, team2, sets, scoreStr, players); + } else { + // Vraag met wie + const keyboard = otherPlayers.map(p => + [{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }] + ); + // Als 3 anderen, ook optie "ik was alleen"? Nee, padel is altijd dubbel + setUserState(chatId, { + step: 'awaiting_teammate', + matchId: match.id, + player: player, + sets: sets, + scoreStr: scoreStr + }); + sendMsg(chatId, 'Met wie vormde je een team?', { + reply_markup: { inline_keyboard: keyboard } + }); + } +} + +async function submitScore(chatId, player, match, team1, team2, sets, scoreStr, players) { + try { + const matchId = match.id; + + // Double-submit guard: check of er al een pending score is + try { + const freshMatches = await api('/matches'); + const freshMatch = Array.isArray(freshMatches) ? freshMatches.find(m => m.id === matchId) : null; + if (freshMatch && freshMatch.score && freshMatch.score.status === 'pending') { + sendMsg(chatId, 'Er is al een score ingediend voor deze wedstrijd. Wacht tot verificatie.'); + return false; + } + } catch(e) { + // Als check faalt, ga door — beter een dubbele score dan een false positive + logError('submitScore double-submit check error:', e.message); + } + + // Bepaal winnaar (team1 of team2) + let team1Wins = 0, team2Wins = 0; + for (const s of sets) { + if (s.team1 > s.team2) team1Wins++; + else team2Wins++; + } + const winningTeam = team1Wins > team2Wins ? team1 : team2; + const losingTeam = team1Wins > team2Wins ? team2 : team1; + + // Bepaal welke sets team1/team2 gewonnen hebben (voor weergave) + const t1 = team1Wins > team2Wins ? team1 : team2; // winnende team + const t2 = team1Wins > team2Wins ? team2 : team1; // verliezende team + + const t1Names = t1.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + const t2Names = t2.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + + // Verstuur naar API + const updated = await api('/matches/' + matchId + '/score', 'POST', { + team1: t1, + team2: t2, + score: scoreStr, + sets: sets, + submitted_by: player.id + }); + + log('Score submitted for match', matchId, 'by', player.id, ':', scoreStr); + + // Stuur bevestiging naar indienner + let setLines = sets.map((s, i) => 'Set ' + (i + 1) + ': ' + s.team1 + '-' + s.team2).join('\n'); + await sendMsg(chatId, + '✅ **Score ingediend!**\n\n' + + setLines + '\n\n' + + '🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' + + '📋 Status: **wacht op verificatie**\n' + + 'Een tegenstander krijgt een bericht om te verifiëren.' + ); + + // Stuur verificatie-bericht naar 1 tegenstander (niet de indienner) + // Eerst proberen van losing team, dan van winning team, nooit de submitter zelf + const losingCandidates = t2.filter(pid => pid !== player.id); + const winningCandidates = t1.filter(pid => pid !== player.id); + const verifierId = losingCandidates.length > 0 ? losingCandidates[0] + : winningCandidates.length > 0 ? winningCandidates[0] + : null; + + if (verifierId === null) { + log('No verifier found for match', matchId, '(all players are submitter?)'); + } else { + const verifier = players.find(p => p.id === verifierId); + + if (verifier && verifier.telegram_id) { + await sendMsg(verifier.telegram_id, + '📋 **Score verificatie**\n\n' + + 'Wedstrijd: ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\n' + + setLines + '\n\n' + + '🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' + + 'Ingevuld door: ' + player.name + '\n\n' + + 'Klopt deze score?', + { + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, klopt!', callback_data: 'score_verify_' + matchId + '_' + verifierId }], + [{ text: '❌ Nee, corrigeer', callback_data: 'score_reject_' + matchId + '_' + verifierId }] + ] + } + } + ); + log('Verification sent to', verifier.name, '(tg:', verifier.telegram_id, ')'); + } else { + log('No verifier found for match', matchId, '(no telegram_id)'); + } + } + + return true; + } catch (e) { + logError('submitScore error:', e.message); + sendMsg(chatId, 'Fout bij opslaan score: ' + e.message); + return false; + } +} + +// ─── Message handler (registratie stappen) ─── +bot.on('message', async (msg) => { + const chatId = msg.chat.id; + const text = msg.text; + if (!text || text.startsWith('/')) return; + + const state = userState.get(chatId); + if (!state) return; + + // ─── Score input (awaiting sets) ─── + if (state.step === 'awaiting_score' || state.step === 'awaiting_score_input') { + if (!state.player) { userState.delete(chatId); return sendMsg(chatId, 'Session verlopen. Stuur /score opnieuw.'); } + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === state.matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + const player = state.player; + await handleScoreInput(chatId, player, match, text, players); + } catch(e) { + logError('score message handler:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } + return; + } + + // ─── Score correction (rejected by verifier) ─── + if (state.step === 'awaiting_score_correction') { + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === state.matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + const parts = text.split(/[\s,]+/).filter(Boolean); + const sets = []; + const setStrParts = []; + for (const p of parts) { + const sp = p.split('-'); + if (sp.length !== 2) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik 6-4 7-5'); + const s1 = parseInt(sp[0], 10), s2 = parseInt(sp[1], 10); + if (isNaN(s1) || isNaN(s2)) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '"'); + sets.push({ team1: s1, team2: s2 }); + setStrParts.push(p); + } + if (sets.length < 2) return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in.'); + const scoreStr = setStrParts.join(', '); + const verifierPlayer = players.find(p => p.id === state.player.id); + if (!verifierPlayer) return sendMsg(chatId, 'Speler niet gevonden.'); + const otherPlayerIds = (match.players || []).filter(pid => pid !== state.player.id); + const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean); + userState.set(chatId, { + step: 'awaiting_teammate_correction', + matchId: match.id, + player: verifierPlayer, + sets: sets, + scoreStr: scoreStr + }); + const keyboard = otherPlayers.map(p => [{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }]); + sendMsg(chatId, 'Met wie vormde jij een team?', { + reply_markup: { inline_keyboard: keyboard } + }); + } catch(e) { + logError('score correction handler:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } + return; + } + + if (state.step === 'register_name') { + state.data.name = text; + state.step = 'register_level'; + sendMsg(chatId, 'Mooi, ' + text + '! Wat is je padel level? (1-10)'); + return; + } + + if (state.step === 'register_level') { + const level = parseInt(text); + if (isNaN(level) || level < 1 || level > 10) { + return sendMsg(chatId, 'Kies een level tussen 1 en 10.'); + } + state.data.level = level; + state.step = 'register_position'; + const keyboard = [ + [{ text: 'Links 🎾', callback_data: 'pos_Links' }, + { text: 'Rechts 🎾', callback_data: 'pos_Rechts' }], + [{ text: 'Beide 🎾', callback_data: 'pos_Beide' }, + { text: 'Onbekend', callback_data: 'pos_Onbekend' }] + ]; + sendMsg(chatId, 'Wat is je voorkeurspositie?', { + reply_markup: { inline_keyboard: keyboard } + }); + } +}); + +// ─── Callback queries ─── +bot.on('callback_query', async (query) => { + const chatId = query.message.chat.id; + const data = query.data; + const state = userState.get(chatId); + + // Position selection + if (data.startsWith('pos_')) { + const position = data.replace('pos_', ''); + if (state && state.step === 'register_position') { + state.data.position = position; + state.data.pin = String(Math.floor(1000 + Math.random() * 9000)); + + try { + const player = await api('/players', 'POST', { + name: state.data.name, + level: state.data.level, + position: state.data.position, + telegram_id: chatId, + pin: state.data.pin, + }); + userState.delete(chatId); + sendMsg(chatId, '✅ Geregistreerd!\n\nNaam: ' + state.data.name + '\nLevel: ' + state.data.level + '\nPositie: ' + position + '\nJe PIN: ' + state.data.pin + '\n\n🌐 Ga naar de webpagina om in te loggen:\nhttps://padel.iamdoingthings.com'); + } catch (e) { + sendMsg(chatId, 'Fout bij registratie: ' + e.message); + } + } + } + + // Availability — entrypoints from /beschikbaar menu + if (data === 'avail_view') { + try { + const players = await api('/players'); + const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null; + if (!player) return; + const avail = player.availability || {}; + const dayNames = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag']; + const dayShort = ['ma','di','wo','do','vr','za','zo']; + const hasAvail = dayNames.filter(d => avail[d] && avail[d].length > 0); + let msg = '\uD83D\uDCC5 **Jouw beschikbaarheid**\n\n'; + if (hasAvail.length === 0) msg += 'Nog geen beschikbaarheid ingesteld.\n'; + else { + for (const d of hasAvail) { + const times = avail[d].map(s => s.start + '-' + s.end).join(', '); + msg += '\uD83D\uDFE2 ' + dayShort[dayNames.indexOf(d)] + ' ' + times + '\n'; + } + } + await sendMsg(chatId, msg, { parse_mode: 'Markdown' }); + } catch(e) { logError('avail_view:', e.message); } + } + + if (data === 'avail_clear') { + try { + const players = await api('/players'); + const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null; + if (!player) return; + await api('PUT', '/players/' + player.id + '/availability', { availability: {}, avail_mode: 'flex' }); + await sendMsg(chatId, '\u2705 Beschikbaarheid gewist!'); + } catch(e) { logError('avail_clear:', e.message); } + } + + if (data === 'avail_add_weekly' || data === 'avail_add_once') { + const isWeekly = data === 'avail_add_weekly'; + try { + const players = await api('/players'); + const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null; + if (!player) return; + const existingAvail = JSON.parse(JSON.stringify(player.availability || {})); + setUserState(chatId, { + step: 'avail_day', + data: { player: player, availability: existingAvail, mode: isWeekly ? 'weekly' : 'flex' } + }); + } catch(e) { logError('avail_add state:', e.message); } + + const days = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag']; + const keyboard = days.map(d => [{ text: d, callback_data: 'avail_day_' + d }]); + keyboard.push([{ text: '\u2705 Klaar met instellen', callback_data: 'avail_done' }]); + const modeLabel = isWeekly ? 'vast weekschema' : 'eenmalig'; + sendMsg(chatId, 'Kies een dag om toe te voegen aan je ' + modeLabel + ':', { + reply_markup: { inline_keyboard: keyboard } + }); + } + + // Availability — day selection + if (data.startsWith('avail_day_')) { + const day = data.replace('avail_day_', ''); + // Zet userState voor slot toggle + const existingState = userState.get(chatId); + if (!existingState || !existingState.data.player) { + // Initialiseer state via player lookup + setUserState(chatId, { step: 'avail_day', data: { availability: {}, player: null } }); + } + // Zoek player + if (!userState.get(chatId)?.data?.player) { + try { + const players = await api('/players'); + const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null; + if (player) { + userState.get(chatId).data.player = player; + userState.get(chatId).data.availability = JSON.parse(JSON.stringify(player.availability || {})); + } + } catch(e) { + logError('avail_day_ player lookup:', e.message); + } + } + + const slots = []; + for (let h = 6; h < 23; h++) { + for (let m = 0; m < 60; m += 30) { + slots.push(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')); + } + } + slots.push('23:00'); + + const keyboard = []; + for (let i = 0; i < slots.length - 1; i += 2) { + const row = []; + for (let j = i; j < Math.min(i + 2, slots.length - 1); j++) { + row.push({ text: slots[j] + '-' + slots[j + 1], callback_data: 'avail_slot_' + day + '_' + slots[j] + '_' + slots[j + 1] }); + } + keyboard.push(row); + } + keyboard.push([{ text: '↩️ Terug naar dagen', callback_data: 'avail_back' }]); + + sendMsg(chatId, 'Beschikbaarheid voor **' + day + '**\nKlik op een tijdsblok om het aan/uit te zetten:', { + parse_mode: 'Markdown', + reply_markup: { inline_keyboard: keyboard } + }); + } + + // Availability — slot toggle + if (data.startsWith('avail_slot_')) { + const remainder = data.slice('avail_slot_'.length); // dag_start_end + const parts = remainder.split('_'); + const day = parts[0]; + const start = parts[1]; + const end = parts[2]; + + if (!state || !state.data.availability) return; + + if (!state.data.availability[day]) state.data.availability[day] = []; + + const idx = state.data.availability[day].findIndex(s => s.start === start); + if (idx >= 0) { + state.data.availability[day].splice(idx, 1); + bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' uit ✅', show_alert: false }).catch(() => {}); + } else { + state.data.availability[day].push({ start, end, duration: 90 }); + bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' aan ✅', show_alert: false }).catch(() => {}); + } + + const slots = state.data.availability[day]; + const statusText = slots.length > 0 + ? 'Geselecteerd voor ' + day + ': ' + slots.map(s => s.start + '-' + s.end).join(', ') + : 'Geen blokken geselecteerd voor ' + day; + + bot.editMessageText('Beschikbaarheid voor **' + day + '**\n' + statusText + '\n\nKlik om aan te passen:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'Markdown', + reply_markup: query.message.reply_markup + }).catch(err => logError('editMessageText error:', err.message)); + } + + // Availability — back to day select + if (data === 'avail_back') { + userState.set(chatId, { ...state, step: 'avail_day' }); + const days = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag']; + const keyboard = days.map(d => [{ text: d, callback_data: 'avail_day_' + d }]); + keyboard.push([{ text: '✅ Klaar met instellen', callback_data: 'avail_done' }]); + sendMsg(chatId, 'Kies een dag:', { + reply_markup: { inline_keyboard: keyboard } + }); + } + + // Availability — done + if (data === 'avail_done') { + if (!state || !state.data.player) { + return sendMsg(chatId, '⚠️ Sessie verlopen. Stuur /beschikbaar opnieuw.'); + } + + try { + const mode = state.data.mode || 'flex'; + await api('/players/' + state.data.player.id + '/availability', 'PUT', { + availability: state.data.availability, + avail_mode: mode + }); + userState.delete(chatId); + sendMsg(chatId, '✅ Beschikbaarheid opgeslagen!'); + } catch (e) { + sendMsg(chatId, 'Fout bij opslaan: ' + e.message); + } + } + + // Score — kies match + if (data.startsWith('score_match_')) { + const matchId = parseInt(data.split('_')[2]); + if (!state || state.step !== 'awaiting_score') return; + bot.answerCallbackQuery(query.id).catch(() => {}); + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + const player = state.player; + userState.set(chatId, { step: 'awaiting_score_input', matchId: match.id, player: player }); + sendMsg(chatId, '📋 **Wedstrijd:** ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5`\n`4-6 6-3 10-7`'); + } catch(e) { logError('score_match callback:', e.message); } + } + + // Score — kies teamgenoot + if (data.startsWith('score_teammate_')) { + const parts = data.split('_'); + const matchId = parseInt(parts[2]); + const teammateId = parseInt(parts[3]); + + if (!state || (state.step !== 'awaiting_teammate' && state.step !== 'awaiting_teammate_correction')) return; + bot.answerCallbackQuery(query.id).catch(() => {}); + + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + + const player = state.player; + const team1 = [player.id, teammateId]; + const team2 = (match.players || []).filter(pid => !team1.some(t => t == pid)); + await submitScore(chatId, player, match, team1, team2, state.sets, state.scoreStr, players); + userState.delete(chatId); + } catch(e) { logError('score_teammate callback:', e.message); } + } + + // Score — verifieer + // SECURITY: alleen de verifier die in het callback_data staat mag verifiëren + // check via chatId of deze telegram_id matcht met verifierId + if (data.startsWith('score_verify_')) { + const parts = data.split('_'); + const matchId = parseInt(parts[2]); + const verifierId = parseInt(parts[3]); + + // SECURITY CHECK: wie klikt mag alleen verifiëren als telegram_id matcht + try { + const players = await api('/players'); + const clickingPlayer = players.find(p => p.telegram_id == chatId); + if (!clickingPlayer || clickingPlayer.id !== verifierId) { + bot.answerCallbackQuery(query.id, { text: 'Je bent niet de verifier van deze score ❌' }).catch(() => {}); + return; + } + } catch(e) { + logError('score_verify security check error:', e.message); + return; + } + + bot.answerCallbackQuery(query.id, { text: 'Score geverifieerd! ✅' }).catch(() => {}); + + try { + const players = await api('/players'); + const updated = await api('/matches/' + matchId + '/verify', 'POST', { player_id: verifierId }); + + if (updated && updated.score && updated.score.status === 'confirmed') { + // Score is confirmed! + const t1Names = (updated.score.team1 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + const t2Names = (updated.score.team2 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + const setStr = Array.isArray(updated.score.sets) + ? updated.score.sets.map((s, i) => 'Set ' + (i+1) + ': ' + s.team1 + '-' + s.team2).join(' | ') + : 'Score: ' + updated.score.score; + + // Notify all 4 players + for (const pid of (updated.players || [])) { + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + let msg = '✅ **Score bevestigd!**\n\n' + + 'Wedstrijd: ' + (updated.date || '?') + ' ' + (updated.start || '?') + '\n' + + setStr + '\n\n' + + '🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' + + 'Status: opgeslagen in competitie'; + if (pid === verifierId) { + msg = '✅ **Bedankt voor het verifiëren!**\n\n' + msg; + } + sendMsg(p.telegram_id, msg); + } + } + + // Kort groepsbericht met uitslag (alleen als groep is ingesteld) + const groupChatId = process.env.GROUP_CHAT_ID ? Number(process.env.GROUP_CHAT_ID) : null; + if (groupChatId) { + const setOneLine = Array.isArray(updated.score.sets) + ? updated.score.sets.map((s, i) => s.team1 + '-' + s.team2).join(' ') + : updated.score.score; + sendMsg(groupChatId, + '🏓 **Uitslag**\n' + + t1Names + ' vs ' + t2Names + '\n' + + setOneLine + '\n' + + '✅ Score opgeslagen via @padel_nl_bot' + ); + } + } + + // Verwijder het oorspronkelijke verificatie bericht + try { + bot.editMessageText('✅ Score geverifieerd!', { + chat_id: chatId, + message_id: query.message.message_id + }).catch(() => {}); + } catch(e) { + logError('score_verify editMessageText:', e.message); + } + } catch(e) { + logError('score_verify callback:', e.message); + sendMsg(chatId, 'Fout bij verifiëren: ' + e.message); + } + } + + // Score — wijs af / corrigeer + if (data.startsWith('score_reject_')) { + const parts = data.split('_'); + const matchId = parseInt(parts[2]); + const rejecterId = parseInt(parts[3]); + + bot.answerCallbackQuery(query.id, { text: 'Voer de juiste score in' }).catch(() => {}); + + setUserState(chatId, { + step: 'awaiting_score_correction', + matchId: matchId, + player: { id: rejecterId } + }); + + sendMsg(chatId, 'De score klopt niet. Wat is de juiste score?\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 3-6 10-5`'); + } + + // Match accept/reject + if (data.startsWith('match_accept_') || data.startsWith('match_reject_')) { + const parts = data.split('_'); + const action = parts[1]; + const matchId = parseInt(parts[2]); + + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players response'); + const player = players.find(p => p.telegram_id == chatId); + if (!player) return; + + const response = action === 'accept' ? 'accepted' : 'rejected'; + const updated = await api('/matches/' + matchId + '/respond', 'POST', { + player_id: player.id, + response: response + }); + + bot.answerCallbackQuery(query.id, { text: response === 'accepted' ? 'Aanwezig!' : 'Afgewezen' }).catch(() => {}); + + // Bei match bevestigd: stuur DM naar alle spelers met boeker info + if (updated && updated.status === 'confirmed') { + const bookerName = updated.booker_name || 'Iemand'; + const allNames = await getPlayerNames(updated.players || []); + for (const pid of (updated.players || [])) { + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + var msg = ''; + if (pid === updated.booker_id) { + msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\nJIJ moet de baan boeken! Regel het op tijd.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.'; + } else { + msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\n' + bookerName + ' regelt de baanreservering.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.'; + } + sendMsg(p.telegram_id, msg); + } + } + } + + // Bij afwijzing: stuur DM naar andere spelers + if (response === 'rejected' && updated && updated.status === 'cancelled') { + const rejectName = player.name; + for (const pid of (updated.players || [])) { + if (pid === player.id) continue; + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + sendMsg(p.telegram_id, + rejectName + ' heeft afgezegd voor ' + updated.date + ' ' + updated.start + '.\n\nNiet genoeg spelers meer. Stuur /match voor een nieuwe poging.' + ); + } + } + + // Plan score reminder na eindtijd + 5 min + if (updated.date && updated.end) { + const now = new Date(); + const [endH, endM] = updated.end.split(':').map(Number); + const matchEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endH, endM); + const delay = Math.max(0, matchEnd.getTime() - now.getTime() + 5 * 60 * 1000); + setTimeout(() => { + const playerIds = updated.players || []; + for (const pid of playerIds) { + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + sendMsg(p.telegram_id, '📝 **Wedstrijd is afgelopen!**\n\nStuur /score om de uitslag in te vullen.\n\nVoorbeeld: /score 6-4 7-5', { parse_mode: 'Markdown' }).catch(() => {}); + } + } + }, delay); + log('Score reminder gepland voor match ' + matchId + ' over ' + Math.round(delay/60000) + ' min'); + } + } + + bot.editMessageText('Je hebt gereageerd: ' + response, { + chat_id: chatId, + message_id: query.message.message_id + }).catch(err => logError('editMessageText error:', err.message)); + } catch (e) { + logError('match response error:', e.message); + bot.answerCallbackQuery(query.id, { text: 'Fout' }).catch(() => {}); + } + } +}); + +// ─── Peakz baan check ─── +const { spawn } = require('child_process'); + +// Valideer datum formaat: YYYY-MM-DD +function isValidDateStr(s) { + return /^\d{4}-\d{2}-\d{2}$/.test(s); +} + +// Valideer tijd formaat: HH:MM +function isValidTimeStr(s) { + return /^\d{2}:\d{2}$/.test(s); +} + +async function checkPeakzCourt(dateStr, timeStr) { + // dateStr = '2026-05-29', timeStr = '17:00' + if (!isValidDateStr(dateStr) || !isValidTimeStr(timeStr)) { + logError('Peakz: ongeldige input', dateStr, timeStr); + return null; + } + try { + const result = await new Promise((resolve, reject) => { + const child = spawn('node', ['/app/padel-peakz.js', dateStr, 'Atoomweg', '--json'], { + timeout: 20000, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => stdout += d); + child.stderr.on('data', d => stderr += d); + child.on('close', code => { + if (code !== 0) return reject(new Error('Exit code ' + code + ': ' + stderr.slice(0, 200))); + resolve(stdout); + }); + child.on('error', reject); + }); + const data = JSON.parse(result); + if (!data.available || !Array.isArray(data.available)) return false; + return data.available.some(s => s.time === timeStr); + } catch (e) { + logError('Peakz check error:', e.message); + return null; // null = onbekend (error) + } +} + +// ─── Matching algorithm ─── +async function runMatching() { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players'); + const matches = await api('/matches'); + + const today = new Date(); + const dayNames = ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag']; + + const daySlots = {}; + for (let d = 0; d < 7; d++) { + const date = new Date(today); + date.setDate(today.getDate() + d); + const dayName = dayNames[date.getDay()]; + if (!daySlots[dayName]) daySlots[dayName] = {}; + + for (const p of players) { + const avail = p.availability || {}; + const slots = avail[dayName] || []; + if (slots.length === 0) continue; + + // Gebruik de date uit de loop (vandaag + d dagen) + const matchDateStr = date.toISOString().split('T')[0]; + const rejectedSlots = p.rejected_slots || {}; + + const toMin = s => { const [h, m] = s.split(':').map(Number); return h * 60 + m; }; + + const availSlots = (avail[dayName] || []).sort((a, b) => toMin(a.start) - toMin(b.start)); + if (availSlots.length === 0) continue; + + let blockStart = toMin(availSlots[0].start); + let blockEnd = toMin(availSlots[0].end); + for (let si = 1; si < availSlots.length; si++) { + const s = availSlots[si]; + if (toMin(s.start) === blockEnd) { + blockEnd = toMin(s.end); + } else { + blockEnd = Math.max(blockEnd, toMin(s.end)); + } + } + + const availStart = toMin(availSlots[0].start); + const availEnd = toMin(availSlots[availSlots.length - 1].end); + + for (let t = availStart; t + 90 <= availEnd; t += 30) { + const blockStart = String(Math.floor(t / 60)).padStart(2, '0') + ':' + String(t % 60).padStart(2, '0'); + const blockEnd = String(Math.floor((t + 90) / 60)).padStart(2, '0') + ':' + String((t + 90) % 60).padStart(2, '0'); + const rejectKey = matchDateStr + '_' + blockStart; + if (rejectedSlots[rejectKey]) continue; + const key = dayName + '_' + blockStart + '_' + blockEnd; + if (!daySlots[dayName][key]) daySlots[dayName][key] = new Set(); + daySlots[dayName][key].add(p); + } + } + } + + for (const [day, slots] of Object.entries(daySlots)) { + // Sorteer slots: die met meeste spelers eerst + const sortedSlots = Object.entries(slots) + .map(([key, s]) => ({ key, players: [...s] })) + .filter(s => s.players.length >= 4) + .sort((a, b) => b.players.length - a.players.length); + + for (const { key, players: slotPlayers } of sortedSlots) { + const parts = key.split('_'); + const start = parts[1], end = parts[2]; + + // Bepaal de ECHTE datum (vandaag + d dagen) + const matchDate = new Date(today); + matchDate.setDate(today.getDate() + d); + const matchDateStr = matchDate.toISOString().split('T')[0]; + + // Check of er al een match is op deze dag+tijd + const alreadyMatched = (matches || []).some(m => + (m.date === matchDateStr || m.day === day) && m.start === start + ); + if (alreadyMatched) continue; + + // Peakz check: is er een baan vrij op deze datum+tijd? + const peakzFree = await checkPeakzCourt(matchDateStr, start); + if (peakzFree === false) { + log('Peakz: geen baan vrij op', matchDateStr, start, '— skip'); + continue; + } + if (peakzFree === null) { + log('Peakz: check mislukt voor', matchDateStr, start, '- skip (veilige default)'); + continue; // bij twijfel: geen match voorstellen + } + + // Level-based optimalisatie: kies best passende 4 spelers + // Prioriteit: dichtstbijzijnde levels, bij gelijke keuze balanced teams + let best4 = slotPlayers.slice(0, 4); + + if (slotPlayers.length > 4) { + // Sorteer op level, zoek cluster van 4 met kleinste level-range + const sorted = [...slotPlayers].sort((a, b) => (a.level || 5) - (b.level || 5)); + let bestSpread = Infinity; + for (let i = 0; i <= sorted.length - 4; i++) { + const group = sorted.slice(i, i + 4); + const spread = (group[3].level || 5) - (group[0].level || 5); + if (spread < bestSpread) { + bestSpread = spread; + best4 = group; + } + } + + // Als er meerdere groepen met dezelfde spread zijn, kies die met + // de meest gebalanceerde teams (team1 gem. level ≈ team2 gem. level) + if (slotPlayers.length >= 6) { + let bestBalance = Infinity; + let balanced4 = best4; + for (let i = 0; i <= sorted.length - 4; i++) { + const group = sorted.slice(i, i + 4); + const spread = (group[3].level || 5) - (group[0].level || 5); + if (spread > bestSpread + 1) continue; // alleen bijzelfde spread + // Balanceer teams: hoog+laag vs mid + const t1 = [group[0], group[3]]; + const t2 = [group[1], group[2]]; + const avg1 = (t1[0].level || 5) + (t1[1].level || 5); + const avg2 = (t2[0].level || 5) + (t2[1].level || 5); + const balance = Math.abs(avg1 - avg2); + if (balance < bestBalance) { + bestBalance = balance; + balanced4 = group; + } + } + best4 = balanced4; + } + } + + const match = await api('/matches', 'POST', { + day, start, end, date: matchDateStr, + players: best4.map(p => p.id), + telegram_ids: best4.filter(p => p.telegram_id).map(p => p.telegram_id) + }); + + for (const p of best4) { + if (p.telegram_id) { + const levels = best4.map(x => x.name + ' (Lv.' + (x.level || '?') + ')').join(', '); + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + day + ' ' + matchDateStr + '\n⏰ ' + start + '-' + end + '\n👥 ' + levels + '\n\n🟢 Baan vrij bij Peakz!\nReageer met /matches of stuur ✅/❌', + { parse_mode: 'Markdown' } + ); + } + } + + return { day, start, end, players: best4, match }; + } + } + return null; +} + +// ─── New chat members welcome ─── +bot.on('new_chat_members', async (msg) => { + const chatId = msg.chat.id; + const botId = parseInt(TOKEN.split(':')[0]); + const isBot = msg.new_chat_members.some(m => m.id === botId || m.is_bot); + + if (isBot) { + sendMsg(chatId, '🏓 **Welkom bij Padel Matcher!**\n\nIk help jullie met het vinden van matches en het bijhouden van scores.\n\n**Wat nu?**\n1️⃣ Stuur /setgroup om deze groep in te stellen\n2️⃣ Iedereen registreert via /start in DM met mij\n3️⃣ Stel beschikbaarheid in via: https://padel.iamdoingthings.com\n4️⃣ Stuur /match om te checken of er 4 spelers beschikbaar zijn\n\nCommands:\n/wie — wie is er geregistreerd\n/match — zoek een match\n/setgroup — zet notificaties aan voor deze groep', + { parse_mode: 'Markdown', disable_web_page_preview: true } + ); + } else { + const names = msg.new_chat_members.map(m => m.first_name || m.username || 'Speler').join(', '); + sendMsg(chatId, '🎾 Welkom ' + names + '!\n\nRegistreer via /start in DM met @padel_nl_bot en ontvang een PIN.\nDaarna kun je beschikbaarheid opgeven op: https://padel.iamdoingthings.com', + { disable_web_page_preview: true } + ); + } +}); + +// ─── Bot command menu instellen ─── +bot.setMyCommands([ + { command: 'nu', description: 'Ik kan nu spelen (meteen opslaan)' }, + { command: 'beschikbaar', description: 'Beschikbaarheid beheren' }, + { command: 'start', description: 'Registreren / PIN ophalen' }, + { command: 'score', description: 'Uitslag invoeren (alleen DM)' }, + { command: 'matches', description: 'Openstaande voorstellen bekijken' }, + { command: 'banen', description: 'Vrije banen bij Peakz checken' }, + { command: 'wie', description: 'Overzicht geregistreerde spelers' }, + { command: 'match', description: 'Nieuwe match zoeken' }, + { command: 'feedback', description: 'Stuur feedback naar beheerder' }, + { command: 'ping', description: 'Check of bot online is' }, +]).catch(e => logError('Set commands error:', e.message)); + +// ─── Polling error handler ─── +bot.on('polling_error', (err) => { + logError('Polling error:', err.message); +}); + +log('🤖 Padel Bot started, polling...'); + +// ─── Auto-match HTTP endpoint (for cron) ─── +const httpServer = http.createServer(async (req, res) => { + if (req.url === '/match-check' && req.method === 'POST') { + try { + const result = await runMatching(); + if (result) { + const target = process.env.GROUP_CHAT_ID; + if (target) { + const names = result.players.map(p => p.name).join(', '); + sendMsg(Number(target), + '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌', + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id }, + { text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }] + ] + } + } + ); + } + for (const p of result.players) { + if (p.telegram_id) { + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches', + { parse_mode: 'Markdown' } + ); + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, match: result.match.id })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, match: null })); + } + } catch (e) { + logError('Match-check HTTP error:', e.message); + res.writeHead(500); + res.end(JSON.stringify({ error: e.message })); + } + } else { + res.writeHead(404); + res.end('not found'); + } +}); + +httpServer.listen(PORT, () => { + log('Match-check HTTP server on port ' + PORT); +}); + +// ─── Auto-match elke 30 min ─── +setInterval(async () => { + try { + const result = await runMatching(); + if (result) { + log('Auto-match found:', result.day, result.start); + const target = process.env.GROUP_CHAT_ID; + if (target) { + const names = result.players.map(p => p.name).join(', '); + sendMsg(Number(target), + '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌', + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id }, + { text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }] + ] + } + } + ); + } + for (const p of result.players) { + if (p.telegram_id) { + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches', + { parse_mode: 'Markdown' } + ); + } + } + } + } catch (e) { + logError('Auto-match error:', e.message); + } +}, 30 * 60 * 1000); + +// ─── /banen — Peakz beschikbaarheid checken ─── +bot.onText(/\/banen(@padel_nl_bot)?(\s+(.+))?/, async (msg, match) => { + const chatId = msg.chat.id; + const rest = match ? match[3] : null; + + let targetDate = new Date(); + let targetLocation = 'Atoomweg'; + + const LOCATION_MAP = { + atoomweg: 'Atoomweg', atoom: 'Atoomweg', + euroborg: 'Euroborg', euro: 'Euroborg', + suikerterrein: 'Suikerterrein', suiker: 'Suikerterrein', buiten: 'Suikerterrein' + }; + + if (rest && rest.trim()) { + const parts = rest.trim().split(/\s+/); + const dagMap = { ma:1, di:2, wo:3, do:4, vr:5, za:6, zo:0 }; + const dagNamen = ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag']; + let dateParsed = false; + const eerste = parts[0].toLowerCase(); + + if (dagMap[eerste] !== undefined) { + let dagen = (dagMap[eerste] - new Date().getDay() + 7) % 7; + if (dagen === 0) dagen = 7; + targetDate.setDate(targetDate.getDate() + dagen); + dateParsed = true; + } else if (parts[0].match(/^\d{4}-\d{2}-\d{2}$/)) { + targetDate = new Date(parts[0] + 'T12:00:00'); + dateParsed = true; + } else if (dagNamen.indexOf(eerste) >= 0) { + let dagen = (dagNamen.indexOf(eerste) - new Date().getDay() + 7) % 7; + if (dagen === 0) dagen = 7; + targetDate.setDate(targetDate.getDate() + dagen); + dateParsed = true; + } + + const locPart = dateParsed ? (parts[1] || '').toLowerCase() : parts.join(' ').toLowerCase(); + for (const [alias, loc] of Object.entries(LOCATION_MAP)) { + if (locPart === alias || locPart.includes(alias)) { + targetLocation = loc; + break; + } + } + } + + const dateStr = targetDate.toISOString().split('T')[0]; + + await sendMsg(chatId, '🔍 Bezig met zoeken naar vrije banen bij Peakz ' + targetLocation + '...'); + + try { + if (!isValidDateStr(dateStr)) { + return sendMsg(chatId, '⚠️ Ongeldige datum. Gebruik /banen [dag] of /banen YYYY-MM-DD'); + } + const result = await new Promise((resolve, reject) => { + const child = spawn('node', ['/app/padel-peakz.js', dateStr, targetLocation, '--json'], { + timeout: 20000, stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout = '', stderr = ''; + child.stdout.on('data', d => stdout += d); + child.stderr.on('data', d => stderr += d); + child.on('close', code => { + if (code !== 0) return reject(new Error('Exit code ' + code + ': ' + stderr.slice(0, 200))); + resolve(stdout); + }); + child.on('error', reject); + }); + const data = JSON.parse(result); + if (data.error) return sendMsg(chatId, 'Fout: ' + data.error); + + const dagKort = ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za']; + const d = new Date(data.date + 'T12:00:00'); + const dagLabel = dagKort[d.getDay()] + ' ' + d.getDate() + '/' + (d.getMonth()+1); + const typeIcon = data.indoor ? '🏢' : '🌳'; + const typeLabel = data.indoor ? 'Indoor' : 'Buiten'; + const available = data.available || []; + + let msg = typeIcon + ' **Peakz Padel — ' + data.location + '** (' + typeLabel + ')\n📅 ' + dagLabel + '\n\n'; + + if (available.length > 0) { + msg += '🟢 **Vrije banen** (' + available.length + '):\n'; + const ochtend = available.filter(s => parseInt(s.time) < 12); + const middag = available.filter(s => parseInt(s.time) >= 12 && parseInt(s.time) < 17); + const avond = available.filter(s => parseInt(s.time) >= 17); + if (ochtend.length) msg += '🌅 Ochtend: ' + ochtend.map(s => s.time + (s.price ? ' (€' + s.price + ')' : '')).join(', ') + '\n'; + if (middag.length) msg += '☀️ Middag: ' + middag.map(s => s.time + (s.price ? ' (€' + s.price + ')' : '')).join(', ') + '\n'; + if (avond.length) msg += '🌙 Avond: ' + avond.map(s => s.time + (s.price ? ' (€' + s.price + ')' : '')).join(', ') + '\n'; + + // Weer-check voor buitenbanen + if (!data.indoor && available.length > 0) { + try { + const http = require('http'); + const weatherData = await new Promise((resolve, reject) => { + http.get('http://api.open-meteo.com/v1/forecast?latitude=53.2194&longitude=6.5667&hourly=precipitation_probability,temperature_2m,weathercode&timezone=Europe/Amsterdam&forecast_days=7', (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } }); + }).on('error', reject); + }); + if (weatherData && weatherData.hourly) { + const times = weatherData.hourly.time || []; + const precip = weatherData.hourly.precipitation_probability || []; + const temps = weatherData.hourly.temperature_2m || []; + const weatherLines = available.slice(0, 8).map(s => { + const hour = parseInt(s.time.split(':')[0]); + const idx = times.findIndex(t => new Date(t).getHours() === hour); + if (idx >= 0) { + const p = precip[idx]; + const t = temps[idx]; + const icon = p > 50 ? '🌧️' : p > 20 ? '🌥️' : '☀️'; + return ' ' + s.time + ' ' + icon + ' ' + Math.round(t) + '°C' + (p > 5 ? ' ' + p + '%' : ''); + } + return ' ' + s.time + ' —'; + }).join('\n'); + msg += '\n🌤️ **Weer:**\n' + weatherLines + '\n'; + } + } catch(e) { logError('Weather error:', e.message); } + } + } else { + msg += '🔴 Geen vrije banen deze dag\n'; + } + + msg += '\n💡 Stuur /banen [dag] [locatie]\n'; + msg += ' Bijv: /banen za, /banen za euroborg, /banen za suiker'; + + await sendMsg(chatId, msg, { parse_mode: 'Markdown', disable_web_page_preview: true }); + } catch (e) { + logError('/banen error:', e.message); + sendMsg(chatId, 'Fout bij ophalen: ' + e.message); + } +}); + diff --git a/bot.js b/bot.js new file mode 100644 index 0000000..10161d8 --- /dev/null +++ b/bot.js @@ -0,0 +1,1084 @@ +const TOKEN = process.env.BOT_TOKEN; +if (!TOKEN) { + console.error('[PADEL-BOT][FATAL] BOT_TOKEN not set in environment'); + process.exit(1); +} +const API_URL = process.env.API_URL || 'http://nova-padel-api:3000'; +const PORT = process.env.PORT || 3001; +const GROUP_CHAT_ID = process.env.GROUP_CHAT_ID || null; + +// ─── Simple logger (visible in docker logs) ─── +function log(...args) { + const prefix = new Date().toISOString() + ' [PADEL-BOT]'; + process.stdout.write(prefix + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + '\n'); +} + +function logError(...args) { + const prefix = new Date().toISOString() + ' [PADEL-BOT][ERROR]'; + process.stderr.write(prefix + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + '\n'); +} + +// ─── Dependencies ─── +const TelegramBot = require('node-telegram-bot-api'); +const http = require('http'); + +// ─── Bot init ─── +const bot = new TelegramBot(TOKEN, { polling: true }); + +// ─── Helpers ─── +function api(path, method = 'GET', body = null) { + return new Promise((resolve, reject) => { + const url = new URL(API_URL + '/api' + path); + const opts = { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(opts, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + log('API response', path, res.statusCode, data.slice(0, 100)); + try { + const parsed = JSON.parse(data); + if (Array.isArray(parsed)) { + resolve(parsed); + } else if (parsed && typeof parsed === 'object' && path === '/players') { + resolve(parsed.players || []); + } else { + resolve(parsed); + } + } catch { + reject(new Error('Invalid JSON: ' + data.slice(0, 100))); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +function daysShort() { + const d = ['zo','ma','di','wo','do','vr','za']; + const today = new Date().getDay(); + return [...d.slice(today), ...d.slice(0, today)]; +} + +function daysNL() { + return ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag']; +} + +async function getPlayerNames(playerIds) { + try { + const players = await api('/players'); + const arr = Array.isArray(players) ? players : []; + return playerIds.map(id => { + const p = arr.find(pl => pl.id === id); + return p ? p.name : 'Onbekend'; + }); + } catch { + return playerIds.map(() => '?'); + } +} + +// ─── State per user ─── +const userState = new Map(); + +// ─── Safe sendMessage wrapper ─── +function sendMsg(chatId, text, opts) { + return bot.sendMessage(chatId, text, opts).catch(err => { + logError('sendMessage failed for chat', chatId, ':', err.message); + }); +} + +// ─── Commands ─── + +// /start — registratie +bot.onText(/\/start(@padel_nl_bot)?/, async (msg) => { + const chatId = msg.chat.id; + const chatType = msg.chat.type; + const name = msg.from.first_name || 'Speler'; + + // In groep: redirect naar DM + if (chatType === 'group' || chatType === 'supergroup') { + return sendMsg(chatId, + '🏓 **Registreren werkt alleen in DM.**\n\nStuur /start naar [@padel_nl_bot](https://t.me/padel_nl_bot)', + { parse_mode: 'Markdown' } + ); + } + + // Check of al geregistreerd + try { + const players = await api('/players'); + if (!Array.isArray(players)) { + throw new Error('Invalid response from API'); + } + const existing = players.find(p => p.telegram_id == chatId); + if (existing) { + return sendMsg(chatId, + 'Welkom terug, ' + existing.name + '! 🏓\n\nJe PIN: ' + existing.pin + '\n\n🌐 Ga naar de webpagina om beschikbaarheid in te stellen:\nhttps://padel.iamdoingthings.com\n\nGroep: /wie — wie is er geregistreerd'); + } + } catch (e) { + logError('/start check error:', e.message); + } + + // Start registratie + userState.set(chatId, { step: 'register_name', data: { telegram_id: chatId } }); + sendMsg(chatId, 'Hallo ' + name + '! 🏓\n\nWat is je volledige naam?'); +}); + +// /beschikbaar, /mijn — redirect naar web +bot.onText(/\/beschikbaar(@padel_nl_bot)?|\/mijn(@padel_nl_bot)?/, async (msg) => { + const chatId = msg.chat.id; + sendMsg(chatId, '🌐 **Ga naar de webpagina** voor beschikbaarheid en profiel:\nhttps://padel.iamdoingthings.com\n\nLog in met je PIN (die je kreeg bij registratie).', + { parse_mode: 'Markdown', disable_web_page_preview: true } + ); +}); + +// /matches — openstaande matches +bot.onText(/\/matches(@padel_nl_bot)?/, async (msg) => { + const chatId = msg.chat.id; + try { + const players = await api('/players'); + if (!Array.isArray(players)) { + throw new Error('Invalid response from API'); + } + const player = players.find(p => p.telegram_id == chatId); + if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start'); + + const matches = await api('/matches'); + if (!Array.isArray(matches)) { + throw new Error('Invalid matches response'); + } + const myMatches = matches.filter(m => m.players && m.players.some(pid => pid == player.id) && m.status === 'proposed'); + + if (myMatches.length === 0) { + return sendMsg(chatId, 'Geen openstaande matches.'); + } + + for (const m of myMatches) { + const names = (m.players || []).map(id => { + const p = players.find(pl => pl.id === id); + return p ? p.name : 'Onbekend'; + }); + const resp = m.responses?.[player.id] || 'pending'; + + const keyboard = [ + [{ text: '✅ Accepteren', callback_data: 'match_accept_' + m.id }, + { text: '❌ Negeren', callback_data: 'match_reject_' + m.id }] + ]; + + await sendMsg(chatId, + '**' + m.day + ' ' + m.date + '** — ' + m.start + '-' + m.end + '\n' + names.join(', ') + '\n\nJouw status: ' + resp, + { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } }); + } + } catch (e) { + logError('/matches error:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } +}); + +// /ping — health check +bot.onText(/\/ping(@padel_nl_bot)?/, (msg) => { + sendMsg(msg.chat.id, '🏓 Pong! Bot is live.'); +}); + +// /setgroup — stel notificatie groep in +bot.onText(/\/setgroup(@padel_nl_bot)?/, async (msg) => { + if (msg.chat.type === 'group' || msg.chat.type === 'supergroup') { + process.env.GROUP_CHAT_ID = String(msg.chat.id); + sendMsg(msg.chat.id, '✅ Deze groep is ingesteld voor match notificaties!'); + } else { + sendMsg(msg.chat.id, 'Voeg me eerst toe aan een groep en stuur /setgroup daar.'); + } +}); + +// /wie — who is registered +bot.onText(/\/wie(@padel_nl_bot)?/, async (msg) => { + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid response'); + const list = players.map(p => '• ' + p.name + ' (level ' + (p.level || '?') + ', ' + (p.position || '?') + ')').join('\n'); + sendMsg(msg.chat.id, '🏓 Geregistreerde spelers (' + players.length + '):\n\n' + list); + } catch (e) { + sendMsg(msg.chat.id, 'Fout: ' + e.message); + } +}); + +// /match — zoek een match +bot.onText(/\/match(@padel_nl_bot)?/, async (msg) => { + const targetChat = msg.chat.id; + try { + const result = await runMatching(); + if (!result) { + sendMsg(targetChat, 'Geen match gevonden voor de komende 7 dagen.'); + return; + } + const names = result.players.map(p => p.name).join(', '); + const msgText = '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌'; + const opts = { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id }, + { text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }] + ] + } + }; + + sendMsg(targetChat, msgText, opts); + + for (const p of result.players) { + if (p.telegram_id) { + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer: /matches', + { parse_mode: 'Markdown' } + ); + } + } + } catch (e) { + sendMsg(targetChat, 'Fout: ' + e.message); + } +}); + +// ─── Score state ─── +// Per chatId: { step: 'awaiting_score'|'awaiting_teammate'|'awaiting_verification', matchId, submitted_by, team1, team2, sets, scoreStr } + +// ─── /score — voer score in (DM only) ─── +bot.onText(/\/score(@padel_nl_bot)?(\s+(.+))?/, async (msg, regexMatch) => { + const chatId = msg.chat.id; + const chatType = msg.chat.type; + + // Alleen in DM + if (chatType !== 'private') { + return sendMsg(chatId, '📝 Score invoeren alleen in DM. Stuur het commando naar @padel_nl_bot.'); + } + + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players response'); + const player = players.find(p => p.telegram_id == chatId); + if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start'); + + // Vind completed/confirmed matches waar deze speler in zit + const matches = await api('/matches'); + if (!Array.isArray(matches)) throw new Error('Invalid matches response'); + + // Filter: matches waar speler in zit, status=confirmed of completed, nog geen confirmed score + const openMatches = matches.filter(m => + m.players && m.players.some(pid => pid == player.id) && + (m.status === 'confirmed' || m.status === 'completed') && + (!m.score || m.score.status !== 'confirmed') + ); + + if (openMatches.length === 0) { + return sendMsg(chatId, 'Geen openstaande scores om in te vullen. Je hebt geen recente wedstrijden zonder score.'); + } + + // Als er meerdere zijn, laat kiezen + if (openMatches.length > 1) { + const keyboard = openMatches.map(m => { + const names = (m.players || []).map(pid => { + const p = players.find(pl => pl.id === pid); + return p ? p.name : '?'; + }).join(', '); + return [{ text: m.date + ' ' + m.start + ' — ' + names, callback_data: 'score_match_' + m.id }]; + }); + return sendMsg(chatId, 'Voor welke wedstrijd wil je de score invullen?', { + reply_markup: { inline_keyboard: keyboard } + }); + } + + // Eén match — ga direct naar score invoer + const selectedMatch = openMatches[0]; + + // Zit er al een score bij? Check of deze speler al heeft ingediend + if (selectedMatch.score && selectedMatch.score.status === 'pending') { + // Al ingediend, wacht op verificatie + return sendMsg(chatId, 'Er is al een score ingediend voor deze wedstrijd. Wacht tot een tegenstander verifieert.'); + } + + // regexMatch[3] = alles na /score (de inline parameters) + const rest = regexMatch ? regexMatch[3] : null; + if (rest && rest.trim()) { + // /score 6-3 6-4 [met X] + await handleScoreInput(chatId, player, selectedMatch, rest.trim(), players); + } else { + // /score zonder parameters — vraag sets + userState.set(chatId, { + step: 'awaiting_score', + matchId: selectedMatch.id, + player: player + }); + sendMsg(chatId, '📋 **Wedstrijd:** ' + (selectedMatch.date || '?') + ' ' + (selectedMatch.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5` (2 sets)\n`4-6 6-3 10-7` (3 sets / super tiebreak)', { parse_mode: 'Markdown' }); + } + } catch (e) { + logError('/score error:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } +}); + +async function handleScoreInput(chatId, player, match, input, players) { + // Parse: "6-3 6-4" of "6-3 6-4 met Thomas" + let setsStr, teammateName; + + const metMatch = input.match(/^(.+?)\s+met\s+(.+)$/i); + if (metMatch) { + setsStr = metMatch[1].trim(); + teammateName = metMatch[2].trim(); + } else { + setsStr = input.trim(); + teammateName = null; + } + + // Parse sets: "6-3 6-4" → [{team1:6, team2:3}, {team1:6, team2:4}] + const parts = setsStr.split(/[\s,]+/).filter(Boolean); + const sets = []; + const setStrParts = []; + + for (const p of parts) { + const sp = p.split('-'); + if (sp.length !== 2) { + return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik formaat zoals 6-4 7-5'); + } + const s1 = parseInt(sp[0], 10); + const s2 = parseInt(sp[1], 10); + if (isNaN(s1) || isNaN(s2)) { + return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik getallen zoals 6-4'); + } + sets.push({ team1: s1, team2: s2 }); + setStrParts.push(p); + } + + if (sets.length < 2) { + return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in. Bijvoorbeeld: 6-4 7-5. Of bij 3 sets / super tiebreak: 4-6 6-3 10-7'); + } + + const scoreStr = setStrParts.join(', '); + + // Bepaal winnaar op basis van sets + let team1Wins = 0, team2Wins = 0; + for (const s of sets) { + if (s.team1 > s.team2) team1Wins++; + else team2Wins++; + } + + if (team1Wins === team2Wins) { + return sendMsg(chatId, '⚠️ Gelijkspel in sets. Bij padel kan dat niet — wie heeft de wedstrijd gewonnen?'); + } + + const otherPlayerIds = (match.players || []).filter(pid => pid !== player.id); + const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean); + + if (teammateName) { + // Zoek teamgenoot op naam + const teammate = otherPlayers.find(p => p.name.toLowerCase() === teammateName.toLowerCase()); + if (!teammate) { + return sendMsg(chatId, '⚠️ Kan "' + teammateName + '" niet vinden in deze wedstrijd. Deelnemers: ' + otherPlayers.map(p => p.name).join(', ')); + } + + const team1 = [player.id, teammate.id]; + const team2 = otherPlayerIds.filter(pid => !team1.some(t => t == pid)); + + await submitScore(chatId, player, match, team1, team2, sets, scoreStr, players); + } else { + // Vraag met wie + const keyboard = otherPlayers.map(p => + [{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }] + ); + // Als 3 anderen, ook optie "ik was alleen"? Nee, padel is altijd dubbel + userState.set(chatId, { + step: 'awaiting_teammate', + matchId: match.id, + player: player, + sets: sets, + scoreStr: scoreStr + }); + sendMsg(chatId, 'Met wie vormde je een team?', { + reply_markup: { inline_keyboard: keyboard } + }); + } +} + +async function submitScore(chatId, player, match, team1, team2, sets, scoreStr, players) { + try { + const matchId = match.id; + + // Bepaal winnaar (team1 of team2) + let team1Wins = 0, team2Wins = 0; + for (const s of sets) { + if (s.team1 > s.team2) team1Wins++; + else team2Wins++; + } + const winningTeam = team1Wins > team2Wins ? team1 : team2; + const losingTeam = team1Wins > team2Wins ? team2 : team1; + + // Bepaal welke sets team1/team2 gewonnen hebben (voor weergave) + const t1 = team1Wins > team2Wins ? team1 : team2; // winnende team + const t2 = team1Wins > team2Wins ? team2 : team1; // verliezende team + + const t1Names = t1.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + const t2Names = t2.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + + // Verstuur naar API + const updated = await api('/matches/' + matchId + '/score', 'POST', { + team1: t1, + team2: t2, + score: scoreStr, + sets: sets, + submitted_by: player.id + }); + + log('Score submitted for match', matchId, 'by', player.id, ':', scoreStr); + + // Stuur bevestiging naar indienner + let setLines = sets.map((s, i) => 'Set ' + (i + 1) + ': ' + s.team1 + '-' + s.team2).join('\n'); + await sendMsg(chatId, + '✅ **Score ingediend!**\n\n' + + setLines + '\n\n' + + '🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' + + '📋 Status: **wacht op verificatie**\n' + + 'Een tegenstander krijgt een bericht om te verifiëren.' + ); + + // Stuur verificatie-bericht naar 1 tegenstander (losing team, niet de indienner) + const verifierCandidates = t2.filter(pid => pid !== player.id); + const verifierId = verifierCandidates.length > 0 ? verifierCandidates[0] : t1.filter(pid => pid !== player.id)[0]; + const verifier = players.find(p => p.id === verifierId); + + if (verifier && verifier.telegram_id) { + await sendMsg(verifier.telegram_id, + '📋 **Score verificatie**\n\n' + + 'Wedstrijd: ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\n' + + setLines + '\n\n' + + '🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' + + 'Ingevuld door: ' + player.name + '\n\n' + + 'Klopt deze score?', + { + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, klopt!', callback_data: 'score_verify_' + matchId + '_' + verifierId }], + [{ text: '❌ Nee, corrigeer', callback_data: 'score_reject_' + matchId + '_' + verifierId }] + ] + } + } + ); + log('Verification sent to', verifier.name, '(tg:', verifier.telegram_id, ')'); + } else { + log('No verifier found for match', matchId, '(no telegram_id)'); + } + + return true; + } catch (e) { + logError('submitScore error:', e.message); + sendMsg(chatId, 'Fout bij opslaan score: ' + e.message); + return false; + } +} + +// ─── Message handler (registratie stappen) ─── +bot.on('message', async (msg) => { + const chatId = msg.chat.id; + const text = msg.text; + if (!text || text.startsWith('/')) return; + + const state = userState.get(chatId); + if (!state) return; + + // ─── Score input (awaiting sets) ─── + if (state.step === 'awaiting_score' || state.step === 'awaiting_score_input') { + if (!state.player) { userState.delete(chatId); return sendMsg(chatId, 'Session verlopen. Stuur /score opnieuw.'); } + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === state.matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + const player = state.player; + await handleScoreInput(chatId, player, match, text, players); + } catch(e) { + logError('score message handler:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } + return; + } + + // ─── Score correction (rejected by verifier) ─── + if (state.step === 'awaiting_score_correction') { + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === state.matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + const parts = text.split(/[\s,]+/).filter(Boolean); + const sets = []; + const setStrParts = []; + for (const p of parts) { + const sp = p.split('-'); + if (sp.length !== 2) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik 6-4 7-5'); + const s1 = parseInt(sp[0], 10), s2 = parseInt(sp[1], 10); + if (isNaN(s1) || isNaN(s2)) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '"'); + sets.push({ team1: s1, team2: s2 }); + setStrParts.push(p); + } + if (sets.length < 2) return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in.'); + const scoreStr = setStrParts.join(', '); + const verifierPlayer = players.find(p => p.id === state.player.id); + if (!verifierPlayer) return sendMsg(chatId, 'Speler niet gevonden.'); + const otherPlayerIds = (match.players || []).filter(pid => pid !== state.player.id); + const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean); + userState.set(chatId, { + step: 'awaiting_teammate_correction', + matchId: match.id, + player: verifierPlayer, + sets: sets, + scoreStr: scoreStr + }); + const keyboard = otherPlayers.map(p => [{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }]); + sendMsg(chatId, 'Met wie vormde jij een team?', { + reply_markup: { inline_keyboard: keyboard } + }); + } catch(e) { + logError('score correction handler:', e.message); + sendMsg(chatId, 'Fout: ' + e.message); + } + return; + } + + if (state.step === 'register_name') { + state.data.name = text; + state.step = 'register_level'; + sendMsg(chatId, 'Mooi, ' + text + '! Wat is je padel level? (1-10)'); + return; + } + + if (state.step === 'register_level') { + const level = parseInt(text); + if (isNaN(level) || level < 1 || level > 10) { + return sendMsg(chatId, 'Kies een level tussen 1 en 10.'); + } + state.data.level = level; + state.step = 'register_position'; + const keyboard = [ + [{ text: 'Links 🎾', callback_data: 'pos_Links' }, + { text: 'Rechts 🎾', callback_data: 'pos_Rechts' }], + [{ text: 'Beide 🎾', callback_data: 'pos_Beide' }, + { text: 'Onbekend', callback_data: 'pos_Onbekend' }] + ]; + sendMsg(chatId, 'Wat is je voorkeurspositie?', { + reply_markup: { inline_keyboard: keyboard } + }); + } +}); + +// ─── Callback queries ─── +bot.on('callback_query', async (query) => { + const chatId = query.message.chat.id; + const data = query.data; + const state = userState.get(chatId); + + // Position selection + if (data.startsWith('pos_')) { + const position = data.replace('pos_', ''); + if (state && state.step === 'register_position') { + state.data.position = position; + state.data.pin = String(Math.floor(1000 + Math.random() * 9000)); + + try { + const player = await api('/players', 'POST', { + name: state.data.name, + level: state.data.level, + position: state.data.position, + telegram_id: chatId, + pin: state.data.pin, + }); + userState.delete(chatId); + sendMsg(chatId, '✅ Geregistreerd!\n\nNaam: ' + state.data.name + '\nLevel: ' + state.data.level + '\nPositie: ' + position + '\nJe PIN: ' + state.data.pin + '\n\n🌐 Ga naar de webpagina om in te loggen:\nhttps://padel.iamdoingthings.com'); + } catch (e) { + sendMsg(chatId, 'Fout bij registratie: ' + e.message); + } + } + } + + // Availability — day selection + if (data.startsWith('avail_day_')) { + const day = data.replace('avail_day_', ''); + const slots = []; + for (let h = 6; h < 23; h++) { + for (let m = 0; m < 60; m += 30) { + slots.push(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')); + } + } + slots.push('23:00'); + + const keyboard = []; + for (let i = 0; i < slots.length - 1; i += 2) { + const row = []; + for (let j = i; j < Math.min(i + 2, slots.length - 1); j++) { + row.push({ text: slots[j] + '-' + slots[j + 1], callback_data: 'avail_slot_' + day + '_' + slots[j] + '_' + slots[j + 1] }); + } + keyboard.push(row); + } + keyboard.push([{ text: '↩️ Terug naar dagen', callback_data: 'avail_back' }]); + + sendMsg(chatId, 'Beschikbaarheid voor **' + day + '**\nKlik op een tijdsblok om het aan/uit te zetten:', { + parse_mode: 'Markdown', + reply_markup: { inline_keyboard: keyboard } + }); + } + + // Availability — slot toggle + if (data.startsWith('avail_slot_')) { + const parts = data.split('_'); + const day = parts[2]; + const start = parts[3]; + const end = parts[4]; + + if (!state || !state.data.availability) return; + + if (!state.data.availability[day]) state.data.availability[day] = []; + + const idx = state.data.availability[day].findIndex(s => s.start === start); + if (idx >= 0) { + state.data.availability[day].splice(idx, 1); + bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' uit ✅', show_alert: false }).catch(() => {}); + } else { + state.data.availability[day].push({ start, end, duration: 90 }); + bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' aan ✅', show_alert: false }).catch(() => {}); + } + + const slots = state.data.availability[day]; + const statusText = slots.length > 0 + ? 'Geselecteerd voor ' + day + ': ' + slots.map(s => s.start + '-' + s.end).join(', ') + : 'Geen blokken geselecteerd voor ' + day; + + bot.editMessageText('Beschikbaarheid voor **' + day + '**\n' + statusText + '\n\nKlik om aan te passen:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'Markdown', + reply_markup: query.message.reply_markup + }).catch(err => logError('editMessageText error:', err.message)); + } + + // Availability — back to day select + if (data === 'avail_back') { + userState.set(chatId, { ...state, step: 'avail_day' }); + const days = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag']; + const keyboard = days.map(d => [{ text: d, callback_data: 'avail_day_' + d }]); + keyboard.push([{ text: '✅ Klaar met instellen', callback_data: 'avail_done' }]); + sendMsg(chatId, 'Kies een dag:', { + reply_markup: { inline_keyboard: keyboard } + }); + } + + // Availability — done + if (data === 'avail_done') { + if (!state || !state.data.player) return; + + try { + await api('/players/' + state.data.player.id, 'PUT', { + availability: state.data.availability + }); + userState.delete(chatId); + sendMsg(chatId, '✅ Beschikbaarheid opgeslagen!'); + } catch (e) { + sendMsg(chatId, 'Fout bij opslaan: ' + e.message); + } + } + + // Score — kies match + if (data.startsWith('score_match_')) { + const matchId = parseInt(data.split('_')[2]); + if (!state || state.step !== 'awaiting_score') return; + bot.answerCallbackQuery(query.id).catch(() => {}); + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + const player = state.player; + userState.set(chatId, { step: 'awaiting_score_input', matchId: match.id, player: player }); + sendMsg(chatId, '📋 **Wedstrijd:** ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5`\n`4-6 6-3 10-7`'); + } catch(e) { logError('score_match callback:', e.message); } + } + + // Score — kies teamgenoot + if (data.startsWith('score_teammate_')) { + const parts = data.split('_'); + const matchId = parseInt(parts[2]); + const teammateId = parseInt(parts[3]); + + if (!state || (state.step !== 'awaiting_teammate' && state.step !== 'awaiting_teammate_correction')) return; + bot.answerCallbackQuery(query.id).catch(() => {}); + + try { + const players = await api('/players'); + const matches = await api('/matches'); + const match = matches.find(m => m.id === matchId); + if (!match) return sendMsg(chatId, 'Match niet gevonden.'); + + const player = state.player; + const team1 = [player.id, teammateId]; + const team2 = (match.players || []).filter(pid => !team1.some(t => t == pid)); + await submitScore(chatId, player, match, team1, team2, state.sets, state.scoreStr, players); + userState.delete(chatId); + } catch(e) { logError('score_teammate callback:', e.message); } + } + + // Score — verifieer + if (data.startsWith('score_verify_')) { + const parts = data.split('_'); + const matchId = parseInt(parts[2]); + const verifierId = parseInt(parts[3]); + + bot.answerCallbackQuery(query.id, { text: 'Score geverifieerd! ✅' }).catch(() => {}); + + try { + const players = await api('/players'); + const updated = await api('/matches/' + matchId + '/verify', 'POST', { player_id: verifierId }); + + if (updated && updated.score && updated.score.status === 'confirmed') { + // Score is confirmed! + const t1Names = (updated.score.team1 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + const t2Names = (updated.score.team2 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & '); + const setStr = Array.isArray(updated.score.sets) + ? updated.score.sets.map((s, i) => 'Set ' + (i+1) + ': ' + s.team1 + '-' + s.team2).join('\n') + : 'Score: ' + updated.score.score; + + // Notify all 4 players + for (const pid of (updated.players || [])) { + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + let msg = '✅ **Score bevestigd!**\n\n' + + 'Wedstrijd: ' + (updated.date || '?') + ' ' + (updated.start || '?') + '\n' + + setStr + '\n\n' + + '🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' + + 'Status: opgeslagen in competitie'; + if (pid === verifierId) { + msg = '✅ **Bedankt voor het verifiëren!**\n\n' + msg; + } + sendMsg(p.telegram_id, msg); + } + } + } + + // Verwijder het oorspronkelijke verificatie bericht + bot.editMessageText('✅ Score geverifieerd!', { + chat_id: chatId, + message_id: query.message.message_id + }).catch(() => {}); + } catch(e) { + logError('score_verify callback:', e.message); + sendMsg(chatId, 'Fout bij verifiëren: ' + e.message); + } + } + + // Score — wijs af / corrigeer + if (data.startsWith('score_reject_')) { + const parts = data.split('_'); + const matchId = parseInt(parts[2]); + const rejecterId = parseInt(parts[3]); + + bot.answerCallbackQuery(query.id, { text: 'Voer de juiste score in' }).catch(() => {}); + + userState.set(chatId, { + step: 'awaiting_score_correction', + matchId: matchId, + player: { id: rejecterId } + }); + + sendMsg(chatId, 'De score klopt niet. Wat is de juiste score?\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 3-6 10-5`'); + } + + // Match accept/reject + if (data.startsWith('match_accept_') || data.startsWith('match_reject_')) { + const parts = data.split('_'); + const action = parts[1]; + const matchId = parseInt(parts[2]); + + try { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players response'); + const player = players.find(p => p.telegram_id == chatId); + if (!player) return; + + const response = action === 'accept' ? 'accepted' : 'rejected'; + const updated = await api('/matches/' + matchId + '/respond', 'POST', { + player_id: player.id, + response: response + }); + + bot.answerCallbackQuery(query.id, { text: response === 'accepted' ? 'Aanwezig!' : 'Afgewezen' }).catch(() => {}); + + // Bei match bevestigd: stuur DM naar alle spelers met boeker info + if (updated && updated.status === 'confirmed') { + const bookerName = updated.booker_name || 'Iemand'; + const allNames = await getPlayerNames(updated.players || []); + for (const pid of (updated.players || [])) { + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + var msg = ''; + if (pid === updated.booker_id) { + msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\nJIJ moet de baan boeken! Regel het op tijd.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.'; + } else { + msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\n' + bookerName + ' regelt de baanreservering.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.'; + } + sendMsg(p.telegram_id, msg); + } + } + } + + // Bij afwijzing: stuur DM naar andere spelers + if (response === 'rejected' && updated && updated.status === 'cancelled') { + const rejectName = player.name; + for (const pid of (updated.players || [])) { + if (pid === player.id) continue; + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + sendMsg(p.telegram_id, + rejectName + ' heeft afgezegd voor ' + updated.date + ' ' + updated.start + '.\n\nNiet genoeg spelers meer. Stuur /match voor een nieuwe poging.' + ); + } + } + + // Plan score reminder na eindtijd + 5 min + if (updated.date && updated.end) { + const now = new Date(); + const [endH, endM] = updated.end.split(':').map(Number); + const matchEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endH, endM); + const delay = Math.max(0, matchEnd.getTime() - now.getTime() + 5 * 60 * 1000); + setTimeout(() => { + const playerIds = updated.players || []; + for (const pid of playerIds) { + const p = players.find(pl => pl.id === pid); + if (p && p.telegram_id) { + sendMsg(p.telegram_id, '📝 **Wedstrijd is afgelopen!**\n\nStuur /score om de uitslag in te vullen.\n\nVoorbeeld: /score 6-4 7-5', { parse_mode: 'Markdown' }).catch(() => {}); + } + } + }, delay); + log('Score reminder gepland voor match ' + matchId + ' over ' + Math.round(delay/60000) + ' min'); + } + } + + bot.editMessageText('Je hebt gereageerd: ' + response, { + chat_id: chatId, + message_id: query.message.message_id + }).catch(err => logError('editMessageText error:', err.message)); + } catch (e) { + logError('match response error:', e.message); + bot.answerCallbackQuery(query.id, { text: 'Fout' }).catch(() => {}); + } + } +}); + +// ─── Matching algorithm ─── +async function runMatching() { + const players = await api('/players'); + if (!Array.isArray(players)) throw new Error('Invalid players'); + const matches = await api('/matches'); + + const today = new Date(); + const dayNames = ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag']; + + const daySlots = {}; + for (let d = 0; d < 7; d++) { + const date = new Date(today); + date.setDate(today.getDate() + d); + const dayName = dayNames[date.getDay()]; + if (!daySlots[dayName]) daySlots[dayName] = {}; + + for (const p of players) { + const avail = p.availability || {}; + const slots = avail[dayName] || []; + if (slots.length === 0) continue; + + const dateStr = new Date(today).toISOString().split('T')[0]; + const rejectedSlots = p.rejected_slots || {}; + + const toMin = s => { const [h, m] = s.split(':').map(Number); return h * 60 + m; }; + + const availSlots = (avail[dayName] || []).sort((a, b) => toMin(a.start) - toMin(b.start)); + if (availSlots.length === 0) continue; + + let blockStart = toMin(availSlots[0].start); + let blockEnd = toMin(availSlots[0].end); + for (let si = 1; si < availSlots.length; si++) { + const s = availSlots[si]; + if (toMin(s.start) === blockEnd) { + blockEnd = toMin(s.end); + } else { + blockEnd = Math.max(blockEnd, toMin(s.end)); + } + } + + const availStart = toMin(availSlots[0].start); + const availEnd = toMin(availSlots[availSlots.length - 1].end); + + for (let t = availStart; t + 90 <= availEnd; t += 30) { + const blockStart = String(Math.floor(t / 60)).padStart(2, '0') + ':' + String(t % 60).padStart(2, '0'); + const blockEnd = String(Math.floor((t + 90) / 60)).padStart(2, '0') + ':' + String((t + 90) % 60).padStart(2, '0'); + const rejectKey = dateStr + '_' + blockStart; + if (rejectedSlots[rejectKey]) continue; + const key = dayName + '_' + blockStart + '_' + blockEnd; + if (!daySlots[dayName][key]) daySlots[dayName][key] = []; + daySlots[dayName][key].push(p); + } + } + } + + for (const [day, slots] of Object.entries(daySlots)) { + for (const [key, slotPlayers] of Object.entries(slots)) { + if (slotPlayers.length >= 4) { + const parts = key.split('_'); + const start = parts[1], end = parts[2]; + + const dateStr = new Date(today).toISOString().split('T')[0]; + const alreadyMatched = (matches || []).some(m => + (m.date === dateStr || m.day === day) && m.start === start + ); + if (alreadyMatched) continue; + + const match = await api('/matches', 'POST', { + day, start, end, + players: slotPlayers.map(p => p.id), + telegram_ids: slotPlayers.filter(p => p.telegram_id).map(p => p.telegram_id) + }); + + for (const p of slotPlayers) { + if (p.telegram_id) { + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + day + '\n⏰ ' + start + '-' + end + '\n👥 ' + slotPlayers.map(x => x.name).join(', ') + '\n\nReageer met /matches of stuur ✅/❌', + { parse_mode: 'Markdown' } + ); + } + } + + return { day, start, end, players: slotPlayers, match }; + } + } + } + return null; +} + +// ─── New chat members welcome ─── +bot.on('new_chat_members', async (msg) => { + const chatId = msg.chat.id; + const botId = parseInt(TOKEN.split(':')[0]); + const isBot = msg.new_chat_members.some(m => m.id === botId || m.is_bot); + + if (isBot) { + sendMsg(chatId, '🏓 **Welkom bij Padel Matcher!**\n\nIk help jullie met het vinden van matches en het bijhouden van scores.\n\n**Wat nu?**\n1️⃣ Stuur /setgroup om deze groep in te stellen\n2️⃣ Iedereen registreert via /start in DM met mij\n3️⃣ Stel beschikbaarheid in via: https://padel.iamdoingthings.com\n4️⃣ Stuur /match om te checken of er 4 spelers beschikbaar zijn\n\nCommands:\n/wie — wie is er geregistreerd\n/match — zoek een match\n/setgroup — zet notificaties aan voor deze groep', + { parse_mode: 'Markdown', disable_web_page_preview: true } + ); + } else { + const names = msg.new_chat_members.map(m => m.first_name || m.username || 'Speler').join(', '); + sendMsg(chatId, '🎾 Welkom ' + names + '!\n\nRegistreer via /start in DM met @padel_nl_bot en ontvang een PIN.\nDaarna kun je beschikbaarheid opgeven op: https://padel.iamdoingthings.com', + { disable_web_page_preview: true } + ); + } +}); + +// ─── Bot command menu instellen ─── +bot.setMyCommands([ + { command: 'start', description: 'Registreren / PIN ophalen' }, + { command: 'wie', description: 'Wie is er geregistreerd' }, + { command: 'match', description: 'Zoek een match' }, + { command: 'setgroup', description: 'Stel deze groep in voor notificaties' }, + { command: 'ping', description: 'Check of de bot leeft' }, +]).catch(e => logError('Set commands error:', e.message)); + +// ─── Polling error handler ─── +bot.on('polling_error', (err) => { + logError('Polling error:', err.message); +}); + +log('🤖 Padel Bot started, polling...'); + +// ─── Auto-match HTTP endpoint (for cron) ─── +const httpServer = http.createServer(async (req, res) => { + if (req.url === '/match-check' && req.method === 'POST') { + try { + const result = await runMatching(); + if (result) { + const target = process.env.GROUP_CHAT_ID; + if (target) { + const names = result.players.map(p => p.name).join(', '); + sendMsg(Number(target), + '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌', + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id }, + { text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }] + ] + } + } + ); + } + for (const p of result.players) { + if (p.telegram_id) { + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches', + { parse_mode: 'Markdown' } + ); + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, match: result.match.id })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, match: null })); + } + } catch (e) { + logError('Match-check HTTP error:', e.message); + res.writeHead(500); + res.end(JSON.stringify({ error: e.message })); + } + } else { + res.writeHead(404); + res.end('not found'); + } +}); + +httpServer.listen(PORT, () => { + log('Match-check HTTP server on port ' + PORT); +}); + +// ─── Auto-match elke 30 min ─── +setInterval(async () => { + try { + const result = await runMatching(); + if (result) { + log('Auto-match found:', result.day, result.start); + const target = process.env.GROUP_CHAT_ID; + if (target) { + const names = result.players.map(p => p.name).join(', '); + sendMsg(Number(target), + '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌', + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id }, + { text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }] + ] + } + } + ); + } + for (const p of result.players) { + if (p.telegram_id) { + sendMsg(p.telegram_id, + '🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches', + { parse_mode: 'Markdown' } + ); + } + } + } + } catch (e) { + logError('Auto-match error:', e.message); + } +}, 30 * 60 * 1000); diff --git a/padel-peakz.js b/padel-peakz.js new file mode 100644 index 0000000..b483278 --- /dev/null +++ b/padel-peakz.js @@ -0,0 +1,97 @@ +const { chromium } = require('playwright'); + +const LOCATIONS = [ + { name: 'Atoomweg', city: 'Groningen', indoor: true, courtTypeIds: '13' }, + { name: 'Euroborg', city: 'Groningen', indoor: true, courtTypeIds: '5' }, + { name: 'Suikerterrein', city: 'Groningen', indoor: false, courtTypeIds: '' }, +]; + +const PLAYING_TIME = '90'; + +function findLocation(nameOrAlias) { + const lower = nameOrAlias.toLowerCase(); + for (const loc of LOCATIONS) { + if (loc.name.toLowerCase() === lower || loc.city.toLowerCase() === lower) return loc; + } + if (lower === 'ataomweg' || lower === 'atoom') return LOCATIONS[0]; + if (lower === 'euro') return LOCATIONS[1]; + if (lower === 'suiker') return LOCATIONS[2]; + return null; +} + +async function getAvailability(dateStr, locationName) { + const loc = findLocation(locationName) || LOCATIONS[0]; + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-gpu'], executablePath: '/usr/bin/chromium-browser' }); + const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' }); + const page = await context.newPage(); + + try { + // Bouw de URL met de juiste parameters per locatie + let url = `https://www.peakzpadel.nl/reserveren/court-booking/reservation?daypart=---&date=${encodeURIComponent(dateStr)}&location=${encodeURIComponent(loc.name)}&playingTimes=${PLAYING_TIME}`; + if (loc.courtTypeIds) { + url += '&courtTypeIds=' + loc.courtTypeIds; + } + + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(3000); + + const slots = await page.evaluate(() => { + const buttons = document.querySelectorAll('button'); + const results = []; + for (const btn of buttons) { + const text = btn.textContent.trim(); + if (text.match(/^\d{2}:\d{2}/)) { + const price = text.match(/€\s*([\d,]+)/); + results.push({ + time: text.split(' ')[0], + price: price ? price[1] : null, + available: !btn.disabled + }); + } + } + return results; + }); + + return { + date: dateStr, + location: loc.name, + indoor: loc.indoor, + slots, + available: slots.filter(s => s.available), + unavailable: slots.filter(s => !s.available) + }; + } catch (err) { + return { error: err.message }; + } finally { + await browser.close(); + } +} + +const dateArg = process.argv[2] || new Date().toISOString().split('T')[0]; +const locationArg = process.argv[3] || 'Atoomweg'; + +getAvailability(dateArg, locationArg).then(result => { + if (result.error) { console.error('Fout:', result.error); process.exit(1); } + if (process.argv.includes('--json')) { console.log(JSON.stringify(result)); process.exit(0); } + + const dayNames = ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za']; + const d = new Date(result.date); + const dayLabel = dayNames[d.getDay()] + ' ' + d.getDate() + '/' + (d.getMonth()+1); + const typeIcon = result.indoor ? '🏢' : '🌳'; + const typeLabel = result.indoor ? 'Indoor' : 'Buiten'; + + console.log(`\n${typeIcon} Peakz Padel — ${result.location} (${typeLabel})`); + console.log(`📅 ${dayLabel}`); + console.log(`🟢 ${result.available.length} banen vrij`); + console.log(`🔴 ${result.unavailable.length} banen bezet`); + console.log(''); + if (result.available.length > 0) { + console.log('Vrije tijden:'); + for (const s of result.available.slice(0, 15)) { + console.log(` 🟢 ${s.time}${s.price ? ' — €' + s.price : ''}`); + } + if (result.available.length > 15) console.log(` ... en ${result.available.length - 15} meer`); + } + console.log(''); + process.exit(0); +});