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; // DM-only check: in groep wordt commando genegeerd function isDM(msg) { return msg.chat.type === 'private'; } // ─── 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 — redirect naar web bot.onText(/\/beschikbaar(@padel_nl_bot)?/, async (msg) => { const chatId = msg.chat.id; sendMsg(chatId, '🌐 **Ga naar de webpagina** voor beschikbaarheid:\nhttps://padel.iamdoingthings.com', { parse_mode: 'Markdown', disable_web_page_preview: true } ); }); // /mijn — DM-only stats + PIN bot.onText(/\/mijn(@padel_nl_bot)?/, async (msg) => { const chatId = msg.chat.id; if (!isDM(msg)) { return sendMsg(chatId, '📊 **Jouw stats** zijn alleen in DM zichtbaar.\n\nStuur /mijn naar [@padel_nl_bot](https://t.me/padel_nl_bot)', { parse_mode: 'Markdown' } ); } 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, 'Je bent niet geregistreerd. Stuur /start om te beginnen.'); const matches = await api('/matches'); if (!Array.isArray(matches)) throw new Error('Invalid matches response'); // Alleen completed matches tellen (confirmed = nog niet gespeeld) const playerMatches = matches.filter(m => m.players && m.players.some(pid => pid == player.id) && m.status === 'completed' ); // Win/loss/draw obv sets: score heeft team1 (winnaar) en team2 (verliezer), // of bij gelijk aantal sets (1-1) is het een draw let won = 0, lost = 0, draw = 0; for (const m of playerMatches) { if (!m.score || !m.score.team1 || !m.score.team2) continue; const inTeam1 = (m.score.team1 || []).some(pid => pid == player.id); const inTeam2 = (m.score.team2 || []).some(pid => pid == player.id); // team1 = winnende team, team2 = verliezende team (zie submitScore logic) if (inTeam1) won++; else if (inTeam2) lost++; else draw++; // speler zit niet in winning/losing team (unlikely) } const played = playerMatches.length; const sessions = await api('/sessions') || []; const playerSessions = (sessions || []).filter(s => s.players && s.players.some(pid => pid == player.id || pid === player.id) ); const totalHours = playerSessions.reduce((sum, s) => { const start = s.start || (s.data || {}).start || "00:00"; const end = s.end || (s.data || {}).end || "00:00"; const [sh, sm] = start.split(":").map(Number); const [eh, em] = end.split(":").map(Number); return sum + ((eh + em / 60) - (sh + sm / 60)); }, 0); const level = player.level || 'onbekend'; const position = player.position || 'onbekend'; let msg = '📊 **Jouw Statistieken**\n\n'; msg += '👤 ' + player.name + '\n'; msg += '🎯 Niveau: ' + level + ' | Positie: ' + position + '\n'; msg += '🔑 PIN: `' + player.pin + '`\n\n'; msg += '🎾 Wedstrijden: ' + played + ' (' + won + '⚡ win / ' + lost + '❌ verlies' + (draw > 0 ? ' / ' + draw + '🤝 draw' : '') + ')\n'; msg += '⏱️ Totaal uren: ' + Math.round(totalHours * 10) / 10 + 'h\n'; msg += '\n[Wijzig profiel](https://padel.iamdoingthings.com) — je PIN is je inlogcode.'; sendMsg(chatId, msg, { parse_mode: 'Markdown' }); } catch (e) { logError('/mijn error:', e.message); sendMsg(chatId, '❌ Kon stats niet laden. Probeer later opnieuw.'); } }); // /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; if (!isDM(msg)) return; // alleen DM 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; if (!isDM(msg)) return; // alleen DM 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); const temp = player.availability_temp || null; const tempKeys = temp ? Object.keys(temp) : []; let msg = '\uD83D\uDCC5 **Jouw beschikbaarheid**\n\n'; if (hasAvail.length === 0 && tempKeys.length === 0) { msg += 'Nog geen beschikbaarheid ingesteld.\n'; } else { if (hasAvail.length > 0) { msg += '_Vaste dagen:_\n'; 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'; } } if (tempKeys.length > 0) { msg += '\n_Tijdelijk:_\n'; for (const wk of tempKeys) { const weekAvail = temp[wk]; const dayShort2 = ['ma','di','wo','do','vr','za','zo']; const parts = []; for (const [d, slots] of Object.entries(weekAvail)) { if (Array.isArray(slots) && slots.length > 0) { parts.push(dayShort2[dayNames.indexOf(d)] + ': ' + slots.map(s => s.start + '-' + s.end).join(', ')); } } if (parts.length > 0) msg += '\uD83D\uDCC5 ' + wk + ': ' + parts.join(', ') + '\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 t1 = (result.proposedTeam1 || []).map(p => p.name).join(' + '); const t2 = (result.proposedTeam2 || []).map(p => p.name).join(' + '); const msgText = '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n\n🏆 **Teams:**\n🟡 ' + t1 + '\n🔵 ' + t2 + '\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') { // Toon wist-menu: vaste dagen of tijdelijke 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 temp = player.availability_temp || null; const dayNames = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag']; const dayShort = ['ma','di','wo','do','vr','za','zo']; const hasFixed = dayNames.filter(d => avail[d] && avail[d].length > 0); const tempKeys = temp ? Object.keys(temp) : []; if (hasFixed.length === 0 && tempKeys.length === 0) { await sendMsg(chatId, 'Er is niets om te wissen.'); return; } let msg = '\u274C **Wat wil je wissen?**\n\n'; const keyboard = []; if (hasFixed.length > 0) { keyboard.push([{ text: '\uD83D\uDDD1\uFE0F Vaste dagen wissen', callback_data: 'avail_clear_fixed' }]); } if (tempKeys.length > 0) { keyboard.push([{ text: '\u23F0 Tijdelijke beschikbaarheid wissen', callback_data: 'avail_clear_temp' }]); } keyboard.push([{ text: '\u274C Alles wissen', callback_data: 'avail_clear_all' }]); keyboard.push([{ text: '\u2190 Terug', callback_data: 'avail_back_menu' }]); await sendMsg(chatId, msg, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } }); } catch(e) { logError('avail_clear menu:', e.message); } } if (data === 'avail_back_menu') { 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'; } } 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 } }); } if (data === 'avail_clear_fixed') { 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 Vaste beschikbaarheid gewist!'); } catch(e) { logError('avail_clear_fixed:', e.message); } } if (data === 'avail_clear_temp') { 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_temp: null, avail_mode: player.avail_mode || 'flex' }); await sendMsg(chatId, '\u2705 Tijdelijke beschikbaarheid gewist!'); } catch(e) { logError('avail_clear_temp:', e.message); } } if (data === 'avail_clear_all') { 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: {}, availability_temp: null, avail_mode: 'flex' }); await sendMsg(chatId, '\u2705 Alle beschikbaarheid gewist!'); } catch(e) { logError('avail_clear_all:', e.message); } } if (data === 'avail_add_weekly') { // Direct naar dagkeuze (vaste dagen = wekelijks patroon) 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: 'weekly', weekKey: null } }); } catch(e) { logError('avail_add_weekly 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' }]); sendMsg(chatId, 'Kies een dag om toe te voegen aan je vaste weekschema:', { reply_markup: { inline_keyboard: keyboard } }); } if (data === 'avail_add_once') { // Eerst week kiezen const now = new Date(); const currentYear = now.getFullYear(); // Bereken huidige ISO week function isoWeek(d) { const d2 = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); const dayNum = d2.getUTCDay() || 7; d2.setUTCDate(d2.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d2.getUTCFullYear(), 0, 1)); return Math.ceil((((d2 - yearStart) / 86400000) + 1) / 7); } const thisWeek = currentYear + '-W' + String(isoWeek(now)).padStart(2, '0'); const nextWeek = currentYear + '-W' + String(isoWeek(now) + 1).padStart(2, '0'); const afterWeek = currentYear + '-W' + String(isoWeek(now) + 2).padStart(2, '0'); const keyboard = [ [{ text: '\uD83D\uDCC5 Deze week (' + thisWeek + ')', callback_data: 'avail_week_' + thisWeek }], [{ text: '\uD83D\uDCC6 Volgende week (' + nextWeek + ')', callback_data: 'avail_week_' + nextWeek }], [{ text: '\u2615 Over 2 weken (' + afterWeek + ')', callback_data: 'avail_week_' + afterWeek }], [{ text: '\u2190 Terug', callback_data: 'avail_back_menu' }], ]; sendMsg(chatId, 'Welke week is de eenmalige beschikbaarheid?', { reply_markup: { inline_keyboard: keyboard } }); } if (data.startsWith('avail_week_')) { const weekKey = data.replace('avail_week_', ''); try { const players = await api('/players'); const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null; if (!player) return; const existingTemp = player.availability_temp || {}; const weekAvail = existingTemp[weekKey] || {}; setUserState(chatId, { step: 'avail_day', data: { player: player, availability: weekAvail, mode: 'flex', weekKey: weekKey, existingTemp: existingTemp } }); } catch(e) { logError('avail_week 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_once' }]); sendMsg(chatId, 'Kies een dag voor week ' + weekKey + ':', { 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.'); } if (state.data.mode === 'flex' && state.data.weekKey) return; // negeren, avail_done_once handelt af try { await api('/players/' + state.data.player.id + '/availability', 'PUT', { availability: state.data.availability, avail_mode: 'weekly' }); userState.delete(chatId); sendMsg(chatId, '✅ Vaste beschikbaarheid opgeslagen!'); } catch (e) { sendMsg(chatId, 'Fout bij opslaan: ' + e.message); } } // Availability — done (once / flex for a specific week) if (data === 'avail_done_once') { if (!state || !state.data.player || state.data.mode !== 'flex' || !state.data.weekKey) { return sendMsg(chatId, '⚠️ Sessie verlopen. Stuur /beschikbaar opnieuw.'); } try { const existingTemp = state.data.existingTemp || {}; existingTemp[state.data.weekKey] = state.data.availability; await api('/players/' + state.data.player.id + '/availability', 'PUT', { availability: state.data.player.availability || {}, avail_mode: 'flex', availability_temp: existingTemp }); userState.delete(chatId); sendMsg(chatId, '✅ Beschikbaarheid voor week ' + state.data.weekKey + ' 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]); // Security check: alleen de juiste verifier kan afwijzen const players = await api('/players'); const clickingPlayer = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null; if (!clickingPlayer || Number(clickingPlayer.id) !== rejecterId) { bot.answerCallbackQuery(query.id, { text: 'Je bent niet de verifier ❌' }).catch(() => {}); return; } bot.answerCallbackQuery(query.id, { text: 'Voer de juiste score in' }).catch(() => {}); setUserState(chatId, { step: 'awaiting_score_correction', matchId: matchId, player: clickingPlayer }); 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 (alleen ranked) if (m.match_type === 'ranked' && 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(() => {}); } // ─── Match accept: ranked/friendly stem ─── if (data.startsWith('match_accept_') || data.startsWith('match_reject_')) { const parts = data.split('_'); const action = parts[1]; const matchId = 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; if (action === 'reject') { const updated = await api('/matches/' + matchId + '/respond', 'POST', { player_id: player.id, response: 'rejected' }); bot.answerCallbackQuery(query.id, { text: 'Afgewezen' }).catch(() => {}); if (updated && updated.status === 'cancelled') { 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, player.name + ' heeft afgezegd voor ' + updated.date + ' ' + updated.start + '.\\n\\nNiet genoeg spelers meer. Stuur /match voor een nieuwe poging.'); } } } bot.editMessageText('Je hebt afgezegd.', { chat_id: chatId, message_id: query.message.message_id }).catch(() => {}); return; } const matchInfo = await api('/matches/' + matchId); if (!matchInfo) throw new Error('Match not found'); const getName = id => { const p = players.find(x => x.id == id); return p ? p.name : '?'; }; const t1names = (matchInfo.proposed_teams?.team1 || []).map(getName).join(' + '); const t2names = (matchInfo.proposed_teams?.team2 || []).map(getName).join(' + '); acceptingMatch[matchId] = { players: matchInfo.players || [], date: matchInfo.date, start: matchInfo.start, location: matchInfo.location, proposed_teams: matchInfo.proposed_teams, ranked: new Set(), friendly: new Set() }; bot.answerCallbackQuery(query.id, { text: 'Aanwezig!' }).catch(() => {}); sendMsg(chatId, '🎾 **Je bevestigt deelname!**\\n\\n' + matchInfo.date + ' ' + matchInfo.start + '\\n🟡 ' + t1names + '\\n🔵 ' + t2names + '\\n\\n**Is dit een ranked of friendly wedstrijd?**', { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: '🏆 Ranked', callback_data: 'vote_ranked_' + matchId }, { text: '🟢 Friendly', callback_data: 'vote_friendly_' + matchId }] ] } } ); bot.editMessageText('Je hebt bevestigd — kies ranked of friendly in DM.', { chat_id: chatId, message_id: query.message.message_id }).catch(() => {}); } catch (e) { logError('match response error:', e.message); bot.answerCallbackQuery(query.id, { text: 'Fout' }).catch(() => {}); } } // ─── Vote: ranked/friendly keuze ─── if (data.startsWith('vote_ranked_') || data.startsWith('vote_friendly_')) { const matchId = data.split('_')[2]; const vote = data.startsWith('vote_ranked_') ? 'ranked' : 'friendly'; const m = acceptingMatch[matchId]; if (!m) { bot.answerCallbackQuery(query.id, { text: 'Match niet gevonden of verlopen' }).catch(() => {}); return; } const players = await api('/players'); const player = players.find(p => p.telegram_id == chatId); if (!player) return; if (m.ranked.has(player.id) || m.friendly.has(player.id)) { bot.answerCallbackQuery(query.id, { text: 'Je hebt al gestemd!' }).catch(() => {}); return; } if (vote === 'ranked') m.ranked.add(player.id); else m.friendly.add(player.id); const rankedCount = m.ranked.size; const friendlyCount = m.friendly.size; const totalResponses = rankedCount + friendlyCount; const getName = id => { const p = players.find(x => x.id == id); return p ? p.name : '?'; }; const t1names = (m.proposed_teams?.team1 || []).map(getName).join(' + '); const t2names = (m.proposed_teams?.team2 || []).map(getName).join(' + '); bot.answerCallbackQuery(query.id, { text: 'Je stem is geregistraerd!' }).catch(() => {}); if (rankedCount >= 3) { await finalizeMatch(matchId, 'ranked'); } else if (friendlyCount >= 3) { await finalizeMatch(matchId, 'friendly'); } else if (totalResponses === 4) { await finalizeMatch(matchId, 'friendly'); } else { const stemmen = 'Stemmen: ' + rankedCount + ' ranked, ' + friendlyCount + ' friendly'; bot.editMessageText( '🎾 **Je bevestigt deelname!**\\n\\n' + m.date + ' ' + m.start + '\\n🟡 ' + t1names + '\\n🔵 ' + t2names + '\\n\\n' + stemmen + '\\n\\n**Is dit een ranked of friendly wedstrijd?**', { chat_id: chatId, message_id: query.message.message_id, parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: '🏆 Ranked', callback_data: 'vote_ranked_' + matchId }, { text: '🟢 Friendly', callback_data: 'vote_friendly_' + matchId }] ] } } ).catch(() => {}); } } // ─── Finaliseer match ─── async function finalizeMatch(matchId, finalType) { const players = await api('/players'); const m = acceptingMatch[matchId]; if (!m) return; try { await api('/matches/' + matchId + '/type', 'POST', { match_type: finalType }); } catch(e) {} const getName = id => { const p = players.find(x => x.id == id); return p ? p.name : '?'; }; const t1names = (m.proposed_teams?.team1 || []).map(getName).join(' + '); const t2names = (m.proposed_teams?.team2 || []).map(getName).join(' + '); const typeLabel = finalType === 'ranked' ? '🏆 **Ranked** — score telt mee!' : '🟢 **Friendly**'; for (const pid of m.players) { const p = players.find(x => x.id == pid); if (p && p.telegram_id) { sendMsg(p.telegram_id, '🎾 **Match bevestigd!**\\n\\n' + m.date + ' ' + m.start + '\\n🟡 ' + t1names + '\\n🔵 ' + t2names + '\\n\\n' + typeLabel + '\\n\\nBoek de baan en vergeet niet /score in te vullen na de wedstrijd!', { parse_mode: 'Markdown' } ); } } delete acceptingMatch[matchId]; log('Match ' + matchId + ' finalized as ' + finalType); } } else if (data.startsWith('team_swap_')) { const matchId = data.split('_')[2]; try { const teams = await api('/matches/' + matchId + '/teams'); if (!teams || !teams.teams) { return bot.answerCallbackQuery(query.id, { text: 'Geen teams om te wisselen' }).catch(() => {}); } // Wissel team1 en team2 const { team1, team2 } = teams.teams; await api('/matches/' + matchId + '/teams', 'POST', { team1: team2, team2: team1 }); // Haal spelernamen op const players = await api('/players'); const getName = id => { const p = players.find(x => x.id === id); return p ? p.name : '?'; }; const t1 = team2.map(getName).join(' + '); const t2 = team1.map(getName).join(' + '); // Stuur DM naar alle spelers const matchInfo = await api('/matches/' + matchId); if (matchInfo && Array.isArray(matchInfo.players)) { for (const pid of matchInfo.players) { const p = players.find(x => x.id === pid); if (p && p.telegram_id) { const typeLabelSwap = matchInfo.match_type === 'ranked' ? '🏆 Ranked' : '🟢 Friendly'; sendMsg(p.telegram_id, '🔄 **Teams gewisseld!**\n\n' + typeLabelSwap + '\n\nNieuwe indeling voor ' + matchInfo.date + ' ' + matchInfo.start + ':\n🟡 ' + t1 + '\n🔵 ' + t2, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + matchId }, { text: '❌ Nee', callback_data: 'match_reject_' + matchId }] ] } } ); } } } // Update het originele bericht bot.editMessageText( '🎾 **Match — teams gewisseld!**\n\n📅 ' + (matchInfo ? matchInfo.date + ' ' + matchInfo.start : '') + '\n\n🏆 **Teams:**\n🟡 ' + t1 + '\n🔵 ' + t2 + '\n\nReageer met ✅ of ❌', { chat_id: query.message.chat.id, message_id: query.message.message_id, parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + matchId }, { text: '❌ Nee', callback_data: 'match_reject_' + matchId }], [{ text: '🔄 Weer wisselen', callback_data: 'team_swap_' + matchId }] ] } } ).catch(e => logError('team_swap edit fail:', e.message)); bot.answerCallbackQuery(query.id, { text: 'Teams gewisseld!' }).catch(() => {}); } catch (e) { logError('team_swap error:', e.message); bot.answerCallbackQuery(query.id, { text: 'Fout bij wisselen' }).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, locationName) { // dateStr = '2026-05-29', timeStr = '17:00', locationName = 'Atoomweg'|'Euroborg'|'Suikerterrein' const loc = locationName || 'Atoomweg'; 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, loc, '--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 || {}; let slots = avail[dayName] || []; // Merge vaste beschikbaarheid met tijdelijke (availability_temp) const temp = p.availability_temp || null; if (temp) { // Bereken ISO week voor deze datum const d2 = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum2 = d2.getUTCDay() || 7; d2.setUTCDate(d2.getUTCDate() + 4 - dayNum2); const yearStart = new Date(Date.UTC(d2.getUTCFullYear(), 0, 1)); const weekNum = Math.ceil((((d2 - yearStart) / 86400000) + 1) / 7); const weekKey = date.getFullYear() + '-W' + String(weekNum).padStart(2, '0'); if (temp[weekKey] && temp[weekKey][dayName]) { slots = [...slots, ...temp[weekKey][dayName]]; // Remove duplicates (same start time) slots = slots.filter((s, i, arr) => arr.findIndex(x => x.start === s.start) === i); } } 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] = { offset: d, players: new Set() }; daySlots[dayName][key].players.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.players], offset: s.offset })) .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]; const dayOffset = slotPlayers.offset ?? d; // Bepaal de ECHTE datum (vandaag + offset dagen) const matchDate = new Date(today); matchDate.setDate(today.getDate() + dayOffset); 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: probeer Atoomweg → Euroborg → Suikerterrein const peakzLocations = ['Atoomweg', 'Euroborg', 'Suikerterrein']; let peakzFree = false; let peakzLocation = null; for (const loc of peakzLocations) { const result = await checkPeakzCourt(matchDateStr, start, loc); if (result === true) { peakzFree = true; peakzLocation = loc; break; } if (result === null) { // null = error — onthoud maar ga door naar volgende locatie log('Peakz: check mislukt voor', loc, matchDateStr, start); } } if (!peakzFree) { log('Peakz: geen baan beschikbaar op', matchDateStr, start, '— skip'); continue; } // Weer-check voor Suikerterrein (buitenbaan) if (peakzLocation === 'Suikerterrein') { 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&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 hour = parseInt(start.split(':')[0]); const times = weatherData.hourly.time || []; const precip = weatherData.hourly.precipitation_probability || []; const idx = times.findIndex(t => new Date(t).getHours() === hour); if (idx >= 0 && precip[idx] > 50) { log('Peakz: Suikerterrein heeft regenkans', precip[idx], '% op', matchDateStr, start, '— skip'); continue; } } } catch(e) { log('Peakz: weer-check mislukt voor Suikerterrein — ga door'); } } // 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; } } // Balanceer 4 spelers in 2 teams: hoog + laag vs mid const sorted = [...best4].sort((a, b) => (a.level || 5) - (b.level || 5)); const proposedTeam1 = [sorted[0], sorted[3]]; // hoogste + laagste const proposedTeam2 = [sorted[1], sorted[2]]; // midden twee 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), match_type: 'friendly' }); // Sla voorgestelde teams op await api('/matches/' + match.id + '/teams', 'POST', { team1: proposedTeam1.map(p => p.id), team2: proposedTeam2.map(p => p.id) }); const t1names = proposedTeam1.map(p => p.name).join(' + '); const t2names = proposedTeam2.map(p => p.name).join(' + '); for (const p of best4) { if (p.telegram_id) { sendMsg(p.telegram_id, '🎾 **Match voorgesteld!**\n\n📅 ' + day + ' ' + matchDateStr + '\n⏰ ' + start + '-' + end + '\n\n🏆 **Teams:**\n🟡 ' + t1names + '\n🔵 ' + t2names + '\n\n📍 ' + peakzLocation + '\n\nType: 🟢 Friendly — 1 speler kan deze ranked maken', { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + match.id }, { text: '❌ Nee', callback_data: 'match_reject_' + match.id }], [{ text: '🔄 Andere teams', callback_data: 'team_swap_' + match.id }] ] } } ); } } // Ook naar groeps-chat met team indeling if (targetChat) { sendMsg(targetChat, '🎾 **Match gevonden!**\n\n📅 ' + day + ' ' + matchDateStr + '\n⏰ ' + start + '-' + end + '\n\n🏆 **Teams:**\n🟡 ' + t1names + '\n🔵 ' + t2names + '\n\n📍 ' + peakzLocation + '\n\nType: 🟢 Friendly — 1 speler kan deze ranked maken', { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + match.id }, { text: '❌ Nee', callback_data: 'match_reject_' + match.id }], [{ text: '🔄 Andere teams', callback_data: 'team_swap_' + match.id }] ] } } ); } return { day, start, end, players: best4, match, proposedTeam1, proposedTeam2 }; } } 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: 'mijn', description: 'Jouw stats en PIN (alleen DM)' }, { 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); } });