Files
padel-bot/bot.js
T
2026-05-25 07:52:40 +00:00

2013 lines
80 KiB
JavaScript

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');
const playerMatches = matches.filter(m =>
m.players && m.players.some(pid => pid == player.id) &&
(m.status === 'completed' || m.status === 'confirmed')
);
const won = playerMatches.filter(m => {
if (!m.score || !m.score.winner) return false;
return m.score.winner === 1 ?
(m.score.team1 || []).some(pid => pid == player.id) :
(m.score.team2 || []).some(pid => pid == player.id);
}).length;
const played = playerMatches.length;
const lost = played - won;
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)\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 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') {
// 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
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, 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;
}
}
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📍 ' + peakzLocation + ' — baan vrij!\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: '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);
}
});