Files
root 1fe970171d Initial commit: Padel Planner Telegram bot
- Telegram bot (bot.js) voor beschikbaarheid, matchen en scores
- Padel Peakz integratie (padel-peakz.js)
- CHANGELOG.md en .gitignore
2026-05-24 20:25:32 +00:00

1085 lines
41 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(() => '?');
}
}
// ─── State per user ───
const userState = new Map();
// ─── Safe sendMessage wrapper ───
function sendMsg(chatId, text, opts) {
return bot.sendMessage(chatId, text, opts).catch(err => {
logError('sendMessage failed for chat', chatId, ':', err.message);
});
}
// ─── Commands ───
// /start — registratie
bot.onText(/\/start(@padel_nl_bot)?/, async (msg) => {
const chatId = msg.chat.id;
const chatType = msg.chat.type;
const name = msg.from.first_name || 'Speler';
// In groep: redirect naar DM
if (chatType === 'group' || chatType === 'supergroup') {
return sendMsg(chatId,
'🏓 **Registreren werkt alleen in DM.**\n\nStuur /start naar [@padel_nl_bot](https://t.me/padel_nl_bot)',
{ parse_mode: 'Markdown' }
);
}
// Check of al geregistreerd
try {
const players = await api('/players');
if (!Array.isArray(players)) {
throw new Error('Invalid response from API');
}
const existing = players.find(p => p.telegram_id == chatId);
if (existing) {
return sendMsg(chatId,
'Welkom terug, ' + existing.name + '! 🏓\n\nJe PIN: ' + existing.pin + '\n\n🌐 Ga naar de webpagina om beschikbaarheid in te stellen:\nhttps://padel.iamdoingthings.com\n\nGroep: /wie — wie is er geregistreerd');
}
} catch (e) {
logError('/start check error:', e.message);
}
// Start registratie
userState.set(chatId, { step: 'register_name', data: { telegram_id: chatId } });
sendMsg(chatId, 'Hallo ' + name + '! 🏓\n\nWat is je volledige naam?');
});
// /beschikbaar, /mijn — redirect naar web
bot.onText(/\/beschikbaar(@padel_nl_bot)?|\/mijn(@padel_nl_bot)?/, async (msg) => {
const chatId = msg.chat.id;
sendMsg(chatId, '🌐 **Ga naar de webpagina** voor beschikbaarheid en profiel:\nhttps://padel.iamdoingthings.com\n\nLog in met je PIN (die je kreeg bij registratie).',
{ parse_mode: 'Markdown', disable_web_page_preview: true }
);
});
// /matches — openstaande matches
bot.onText(/\/matches(@padel_nl_bot)?/, async (msg) => {
const chatId = msg.chat.id;
try {
const players = await api('/players');
if (!Array.isArray(players)) {
throw new Error('Invalid response from API');
}
const player = players.find(p => p.telegram_id == chatId);
if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start');
const matches = await api('/matches');
if (!Array.isArray(matches)) {
throw new Error('Invalid matches response');
}
const myMatches = matches.filter(m => m.players && m.players.some(pid => pid == player.id) && m.status === 'proposed');
if (myMatches.length === 0) {
return sendMsg(chatId, 'Geen openstaande matches.');
}
for (const m of myMatches) {
const names = (m.players || []).map(id => {
const p = players.find(pl => pl.id === id);
return p ? p.name : 'Onbekend';
});
const resp = m.responses?.[player.id] || 'pending';
const keyboard = [
[{ text: '✅ Accepteren', callback_data: 'match_accept_' + m.id },
{ text: '❌ Negeren', callback_data: 'match_reject_' + m.id }]
];
await sendMsg(chatId,
'**' + m.day + ' ' + m.date + '** — ' + m.start + '-' + m.end + '\n' + names.join(', ') + '\n\nJouw status: ' + resp,
{ parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } });
}
} catch (e) {
logError('/matches error:', e.message);
sendMsg(chatId, 'Fout: ' + e.message);
}
});
// /ping — health check
bot.onText(/\/ping(@padel_nl_bot)?/, (msg) => {
sendMsg(msg.chat.id, '🏓 Pong! Bot is live.');
});
// /setgroup — stel notificatie groep in
bot.onText(/\/setgroup(@padel_nl_bot)?/, async (msg) => {
if (msg.chat.type === 'group' || msg.chat.type === 'supergroup') {
process.env.GROUP_CHAT_ID = String(msg.chat.id);
sendMsg(msg.chat.id, '✅ Deze groep is ingesteld voor match notificaties!');
} else {
sendMsg(msg.chat.id, 'Voeg me eerst toe aan een groep en stuur /setgroup daar.');
}
});
// /wie — who is registered
bot.onText(/\/wie(@padel_nl_bot)?/, async (msg) => {
try {
const players = await api('/players');
if (!Array.isArray(players)) throw new Error('Invalid response');
const list = players.map(p => '• ' + p.name + ' (level ' + (p.level || '?') + ', ' + (p.position || '?') + ')').join('\n');
sendMsg(msg.chat.id, '🏓 Geregistreerde spelers (' + players.length + '):\n\n' + list);
} catch (e) {
sendMsg(msg.chat.id, 'Fout: ' + e.message);
}
});
// /match — zoek een match
bot.onText(/\/match(@padel_nl_bot)?/, async (msg) => {
const targetChat = msg.chat.id;
try {
const result = await runMatching();
if (!result) {
sendMsg(targetChat, 'Geen match gevonden voor de komende 7 dagen.');
return;
}
const names = result.players.map(p => p.name).join(', ');
const msgText = '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌';
const opts = {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id },
{ text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }]
]
}
};
sendMsg(targetChat, msgText, opts);
for (const p of result.players) {
if (p.telegram_id) {
sendMsg(p.telegram_id,
'🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer: /matches',
{ parse_mode: 'Markdown' }
);
}
}
} catch (e) {
sendMsg(targetChat, 'Fout: ' + e.message);
}
});
// ─── Score state ───
// Per chatId: { step: 'awaiting_score'|'awaiting_teammate'|'awaiting_verification', matchId, submitted_by, team1, team2, sets, scoreStr }
// ─── /score — voer score in (DM only) ───
bot.onText(/\/score(@padel_nl_bot)?(\s+(.+))?/, async (msg, regexMatch) => {
const chatId = msg.chat.id;
const chatType = msg.chat.type;
// Alleen in DM
if (chatType !== 'private') {
return sendMsg(chatId, '📝 Score invoeren alleen in DM. Stuur het commando naar @padel_nl_bot.');
}
try {
const players = await api('/players');
if (!Array.isArray(players)) throw new Error('Invalid players response');
const player = players.find(p => p.telegram_id == chatId);
if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start');
// Vind completed/confirmed matches waar deze speler in zit
const matches = await api('/matches');
if (!Array.isArray(matches)) throw new Error('Invalid matches response');
// Filter: matches waar speler in zit, status=confirmed of completed, nog geen confirmed score
const openMatches = matches.filter(m =>
m.players && m.players.some(pid => pid == player.id) &&
(m.status === 'confirmed' || m.status === 'completed') &&
(!m.score || m.score.status !== 'confirmed')
);
if (openMatches.length === 0) {
return sendMsg(chatId, 'Geen openstaande scores om in te vullen. Je hebt geen recente wedstrijden zonder score.');
}
// Als er meerdere zijn, laat kiezen
if (openMatches.length > 1) {
const keyboard = openMatches.map(m => {
const names = (m.players || []).map(pid => {
const p = players.find(pl => pl.id === pid);
return p ? p.name : '?';
}).join(', ');
return [{ text: m.date + ' ' + m.start + ' — ' + names, callback_data: 'score_match_' + m.id }];
});
return sendMsg(chatId, 'Voor welke wedstrijd wil je de score invullen?', {
reply_markup: { inline_keyboard: keyboard }
});
}
// Eén match — ga direct naar score invoer
const selectedMatch = openMatches[0];
// Zit er al een score bij? Check of deze speler al heeft ingediend
if (selectedMatch.score && selectedMatch.score.status === 'pending') {
// Al ingediend, wacht op verificatie
return sendMsg(chatId, 'Er is al een score ingediend voor deze wedstrijd. Wacht tot een tegenstander verifieert.');
}
// regexMatch[3] = alles na /score (de inline parameters)
const rest = regexMatch ? regexMatch[3] : null;
if (rest && rest.trim()) {
// /score 6-3 6-4 [met X]
await handleScoreInput(chatId, player, selectedMatch, rest.trim(), players);
} else {
// /score zonder parameters — vraag sets
userState.set(chatId, {
step: 'awaiting_score',
matchId: selectedMatch.id,
player: player
});
sendMsg(chatId, '📋 **Wedstrijd:** ' + (selectedMatch.date || '?') + ' ' + (selectedMatch.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5` (2 sets)\n`4-6 6-3 10-7` (3 sets / super tiebreak)', { parse_mode: 'Markdown' });
}
} catch (e) {
logError('/score error:', e.message);
sendMsg(chatId, 'Fout: ' + e.message);
}
});
async function handleScoreInput(chatId, player, match, input, players) {
// Parse: "6-3 6-4" of "6-3 6-4 met Thomas"
let setsStr, teammateName;
const metMatch = input.match(/^(.+?)\s+met\s+(.+)$/i);
if (metMatch) {
setsStr = metMatch[1].trim();
teammateName = metMatch[2].trim();
} else {
setsStr = input.trim();
teammateName = null;
}
// Parse sets: "6-3 6-4" → [{team1:6, team2:3}, {team1:6, team2:4}]
const parts = setsStr.split(/[\s,]+/).filter(Boolean);
const sets = [];
const setStrParts = [];
for (const p of parts) {
const sp = p.split('-');
if (sp.length !== 2) {
return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik formaat zoals 6-4 7-5');
}
const s1 = parseInt(sp[0], 10);
const s2 = parseInt(sp[1], 10);
if (isNaN(s1) || isNaN(s2)) {
return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik getallen zoals 6-4');
}
sets.push({ team1: s1, team2: s2 });
setStrParts.push(p);
}
if (sets.length < 2) {
return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in. Bijvoorbeeld: 6-4 7-5. Of bij 3 sets / super tiebreak: 4-6 6-3 10-7');
}
const scoreStr = setStrParts.join(', ');
// Bepaal winnaar op basis van sets
let team1Wins = 0, team2Wins = 0;
for (const s of sets) {
if (s.team1 > s.team2) team1Wins++;
else team2Wins++;
}
if (team1Wins === team2Wins) {
return sendMsg(chatId, '⚠️ Gelijkspel in sets. Bij padel kan dat niet — wie heeft de wedstrijd gewonnen?');
}
const otherPlayerIds = (match.players || []).filter(pid => pid !== player.id);
const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean);
if (teammateName) {
// Zoek teamgenoot op naam
const teammate = otherPlayers.find(p => p.name.toLowerCase() === teammateName.toLowerCase());
if (!teammate) {
return sendMsg(chatId, '⚠️ Kan "' + teammateName + '" niet vinden in deze wedstrijd. Deelnemers: ' + otherPlayers.map(p => p.name).join(', '));
}
const team1 = [player.id, teammate.id];
const team2 = otherPlayerIds.filter(pid => !team1.some(t => t == pid));
await submitScore(chatId, player, match, team1, team2, sets, scoreStr, players);
} else {
// Vraag met wie
const keyboard = otherPlayers.map(p =>
[{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }]
);
// Als 3 anderen, ook optie "ik was alleen"? Nee, padel is altijd dubbel
userState.set(chatId, {
step: 'awaiting_teammate',
matchId: match.id,
player: player,
sets: sets,
scoreStr: scoreStr
});
sendMsg(chatId, 'Met wie vormde je een team?', {
reply_markup: { inline_keyboard: keyboard }
});
}
}
async function submitScore(chatId, player, match, team1, team2, sets, scoreStr, players) {
try {
const matchId = match.id;
// Bepaal winnaar (team1 of team2)
let team1Wins = 0, team2Wins = 0;
for (const s of sets) {
if (s.team1 > s.team2) team1Wins++;
else team2Wins++;
}
const winningTeam = team1Wins > team2Wins ? team1 : team2;
const losingTeam = team1Wins > team2Wins ? team2 : team1;
// Bepaal welke sets team1/team2 gewonnen hebben (voor weergave)
const t1 = team1Wins > team2Wins ? team1 : team2; // winnende team
const t2 = team1Wins > team2Wins ? team2 : team1; // verliezende team
const t1Names = t1.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
const t2Names = t2.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
// Verstuur naar API
const updated = await api('/matches/' + matchId + '/score', 'POST', {
team1: t1,
team2: t2,
score: scoreStr,
sets: sets,
submitted_by: player.id
});
log('Score submitted for match', matchId, 'by', player.id, ':', scoreStr);
// Stuur bevestiging naar indienner
let setLines = sets.map((s, i) => 'Set ' + (i + 1) + ': ' + s.team1 + '-' + s.team2).join('\n');
await sendMsg(chatId,
'✅ **Score ingediend!**\n\n' +
setLines + '\n\n' +
'🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' +
'📋 Status: **wacht op verificatie**\n' +
'Een tegenstander krijgt een bericht om te verifiëren.'
);
// Stuur verificatie-bericht naar 1 tegenstander (losing team, niet de indienner)
const verifierCandidates = t2.filter(pid => pid !== player.id);
const verifierId = verifierCandidates.length > 0 ? verifierCandidates[0] : t1.filter(pid => pid !== player.id)[0];
const verifier = players.find(p => p.id === verifierId);
if (verifier && verifier.telegram_id) {
await sendMsg(verifier.telegram_id,
'📋 **Score verificatie**\n\n' +
'Wedstrijd: ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\n' +
setLines + '\n\n' +
'🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' +
'Ingevuld door: ' + player.name + '\n\n' +
'Klopt deze score?',
{
reply_markup: {
inline_keyboard: [
[{ text: '✅ Ja, klopt!', callback_data: 'score_verify_' + matchId + '_' + verifierId }],
[{ text: '❌ Nee, corrigeer', callback_data: 'score_reject_' + matchId + '_' + verifierId }]
]
}
}
);
log('Verification sent to', verifier.name, '(tg:', verifier.telegram_id, ')');
} else {
log('No verifier found for match', matchId, '(no telegram_id)');
}
return true;
} catch (e) {
logError('submitScore error:', e.message);
sendMsg(chatId, 'Fout bij opslaan score: ' + e.message);
return false;
}
}
// ─── Message handler (registratie stappen) ───
bot.on('message', async (msg) => {
const chatId = msg.chat.id;
const text = msg.text;
if (!text || text.startsWith('/')) return;
const state = userState.get(chatId);
if (!state) return;
// ─── Score input (awaiting sets) ───
if (state.step === 'awaiting_score' || state.step === 'awaiting_score_input') {
if (!state.player) { userState.delete(chatId); return sendMsg(chatId, 'Session verlopen. Stuur /score opnieuw.'); }
try {
const players = await api('/players');
const matches = await api('/matches');
const match = matches.find(m => m.id === state.matchId);
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
const player = state.player;
await handleScoreInput(chatId, player, match, text, players);
} catch(e) {
logError('score message handler:', e.message);
sendMsg(chatId, 'Fout: ' + e.message);
}
return;
}
// ─── Score correction (rejected by verifier) ───
if (state.step === 'awaiting_score_correction') {
try {
const players = await api('/players');
const matches = await api('/matches');
const match = matches.find(m => m.id === state.matchId);
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
const parts = text.split(/[\s,]+/).filter(Boolean);
const sets = [];
const setStrParts = [];
for (const p of parts) {
const sp = p.split('-');
if (sp.length !== 2) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik 6-4 7-5');
const s1 = parseInt(sp[0], 10), s2 = parseInt(sp[1], 10);
if (isNaN(s1) || isNaN(s2)) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '"');
sets.push({ team1: s1, team2: s2 });
setStrParts.push(p);
}
if (sets.length < 2) return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in.');
const scoreStr = setStrParts.join(', ');
const verifierPlayer = players.find(p => p.id === state.player.id);
if (!verifierPlayer) return sendMsg(chatId, 'Speler niet gevonden.');
const otherPlayerIds = (match.players || []).filter(pid => pid !== state.player.id);
const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean);
userState.set(chatId, {
step: 'awaiting_teammate_correction',
matchId: match.id,
player: verifierPlayer,
sets: sets,
scoreStr: scoreStr
});
const keyboard = otherPlayers.map(p => [{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }]);
sendMsg(chatId, 'Met wie vormde jij een team?', {
reply_markup: { inline_keyboard: keyboard }
});
} catch(e) {
logError('score correction handler:', e.message);
sendMsg(chatId, 'Fout: ' + e.message);
}
return;
}
if (state.step === 'register_name') {
state.data.name = text;
state.step = 'register_level';
sendMsg(chatId, 'Mooi, ' + text + '! Wat is je padel level? (1-10)');
return;
}
if (state.step === 'register_level') {
const level = parseInt(text);
if (isNaN(level) || level < 1 || level > 10) {
return sendMsg(chatId, 'Kies een level tussen 1 en 10.');
}
state.data.level = level;
state.step = 'register_position';
const keyboard = [
[{ text: 'Links 🎾', callback_data: 'pos_Links' },
{ text: 'Rechts 🎾', callback_data: 'pos_Rechts' }],
[{ text: 'Beide 🎾', callback_data: 'pos_Beide' },
{ text: 'Onbekend', callback_data: 'pos_Onbekend' }]
];
sendMsg(chatId, 'Wat is je voorkeurspositie?', {
reply_markup: { inline_keyboard: keyboard }
});
}
});
// ─── Callback queries ───
bot.on('callback_query', async (query) => {
const chatId = query.message.chat.id;
const data = query.data;
const state = userState.get(chatId);
// Position selection
if (data.startsWith('pos_')) {
const position = data.replace('pos_', '');
if (state && state.step === 'register_position') {
state.data.position = position;
state.data.pin = String(Math.floor(1000 + Math.random() * 9000));
try {
const player = await api('/players', 'POST', {
name: state.data.name,
level: state.data.level,
position: state.data.position,
telegram_id: chatId,
pin: state.data.pin,
});
userState.delete(chatId);
sendMsg(chatId, '✅ Geregistreerd!\n\nNaam: ' + state.data.name + '\nLevel: ' + state.data.level + '\nPositie: ' + position + '\nJe PIN: ' + state.data.pin + '\n\n🌐 Ga naar de webpagina om in te loggen:\nhttps://padel.iamdoingthings.com');
} catch (e) {
sendMsg(chatId, 'Fout bij registratie: ' + e.message);
}
}
}
// Availability — day selection
if (data.startsWith('avail_day_')) {
const day = data.replace('avail_day_', '');
const slots = [];
for (let h = 6; h < 23; h++) {
for (let m = 0; m < 60; m += 30) {
slots.push(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'));
}
}
slots.push('23:00');
const keyboard = [];
for (let i = 0; i < slots.length - 1; i += 2) {
const row = [];
for (let j = i; j < Math.min(i + 2, slots.length - 1); j++) {
row.push({ text: slots[j] + '-' + slots[j + 1], callback_data: 'avail_slot_' + day + '_' + slots[j] + '_' + slots[j + 1] });
}
keyboard.push(row);
}
keyboard.push([{ text: '↩️ Terug naar dagen', callback_data: 'avail_back' }]);
sendMsg(chatId, 'Beschikbaarheid voor **' + day + '**\nKlik op een tijdsblok om het aan/uit te zetten:', {
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: keyboard }
});
}
// Availability — slot toggle
if (data.startsWith('avail_slot_')) {
const parts = data.split('_');
const day = parts[2];
const start = parts[3];
const end = parts[4];
if (!state || !state.data.availability) return;
if (!state.data.availability[day]) state.data.availability[day] = [];
const idx = state.data.availability[day].findIndex(s => s.start === start);
if (idx >= 0) {
state.data.availability[day].splice(idx, 1);
bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' uit ✅', show_alert: false }).catch(() => {});
} else {
state.data.availability[day].push({ start, end, duration: 90 });
bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' aan ✅', show_alert: false }).catch(() => {});
}
const slots = state.data.availability[day];
const statusText = slots.length > 0
? 'Geselecteerd voor ' + day + ': ' + slots.map(s => s.start + '-' + s.end).join(', ')
: 'Geen blokken geselecteerd voor ' + day;
bot.editMessageText('Beschikbaarheid voor **' + day + '**\n' + statusText + '\n\nKlik om aan te passen:', {
chat_id: chatId,
message_id: query.message.message_id,
parse_mode: 'Markdown',
reply_markup: query.message.reply_markup
}).catch(err => logError('editMessageText error:', err.message));
}
// Availability — back to day select
if (data === 'avail_back') {
userState.set(chatId, { ...state, step: 'avail_day' });
const days = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
const keyboard = days.map(d => [{ text: d, callback_data: 'avail_day_' + d }]);
keyboard.push([{ text: '✅ Klaar met instellen', callback_data: 'avail_done' }]);
sendMsg(chatId, 'Kies een dag:', {
reply_markup: { inline_keyboard: keyboard }
});
}
// Availability — done
if (data === 'avail_done') {
if (!state || !state.data.player) return;
try {
await api('/players/' + state.data.player.id, 'PUT', {
availability: state.data.availability
});
userState.delete(chatId);
sendMsg(chatId, '✅ Beschikbaarheid opgeslagen!');
} catch (e) {
sendMsg(chatId, 'Fout bij opslaan: ' + e.message);
}
}
// Score — kies match
if (data.startsWith('score_match_')) {
const matchId = parseInt(data.split('_')[2]);
if (!state || state.step !== 'awaiting_score') return;
bot.answerCallbackQuery(query.id).catch(() => {});
try {
const players = await api('/players');
const matches = await api('/matches');
const match = matches.find(m => m.id === matchId);
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
const player = state.player;
userState.set(chatId, { step: 'awaiting_score_input', matchId: match.id, player: player });
sendMsg(chatId, '📋 **Wedstrijd:** ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5`\n`4-6 6-3 10-7`');
} catch(e) { logError('score_match callback:', e.message); }
}
// Score — kies teamgenoot
if (data.startsWith('score_teammate_')) {
const parts = data.split('_');
const matchId = parseInt(parts[2]);
const teammateId = parseInt(parts[3]);
if (!state || (state.step !== 'awaiting_teammate' && state.step !== 'awaiting_teammate_correction')) return;
bot.answerCallbackQuery(query.id).catch(() => {});
try {
const players = await api('/players');
const matches = await api('/matches');
const match = matches.find(m => m.id === matchId);
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
const player = state.player;
const team1 = [player.id, teammateId];
const team2 = (match.players || []).filter(pid => !team1.some(t => t == pid));
await submitScore(chatId, player, match, team1, team2, state.sets, state.scoreStr, players);
userState.delete(chatId);
} catch(e) { logError('score_teammate callback:', e.message); }
}
// Score — verifieer
if (data.startsWith('score_verify_')) {
const parts = data.split('_');
const matchId = parseInt(parts[2]);
const verifierId = parseInt(parts[3]);
bot.answerCallbackQuery(query.id, { text: 'Score geverifieerd! ✅' }).catch(() => {});
try {
const players = await api('/players');
const updated = await api('/matches/' + matchId + '/verify', 'POST', { player_id: verifierId });
if (updated && updated.score && updated.score.status === 'confirmed') {
// Score is confirmed!
const t1Names = (updated.score.team1 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
const t2Names = (updated.score.team2 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
const setStr = Array.isArray(updated.score.sets)
? updated.score.sets.map((s, i) => 'Set ' + (i+1) + ': ' + s.team1 + '-' + s.team2).join('\n')
: 'Score: ' + updated.score.score;
// Notify all 4 players
for (const pid of (updated.players || [])) {
const p = players.find(pl => pl.id === pid);
if (p && p.telegram_id) {
let msg = '✅ **Score bevestigd!**\n\n' +
'Wedstrijd: ' + (updated.date || '?') + ' ' + (updated.start || '?') + '\n' +
setStr + '\n\n' +
'🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' +
'Status: opgeslagen in competitie';
if (pid === verifierId) {
msg = '✅ **Bedankt voor het verifiëren!**\n\n' + msg;
}
sendMsg(p.telegram_id, msg);
}
}
}
// Verwijder het oorspronkelijke verificatie bericht
bot.editMessageText('✅ Score geverifieerd!', {
chat_id: chatId,
message_id: query.message.message_id
}).catch(() => {});
} catch(e) {
logError('score_verify callback:', e.message);
sendMsg(chatId, 'Fout bij verifiëren: ' + e.message);
}
}
// Score — wijs af / corrigeer
if (data.startsWith('score_reject_')) {
const parts = data.split('_');
const matchId = parseInt(parts[2]);
const rejecterId = parseInt(parts[3]);
bot.answerCallbackQuery(query.id, { text: 'Voer de juiste score in' }).catch(() => {});
userState.set(chatId, {
step: 'awaiting_score_correction',
matchId: matchId,
player: { id: rejecterId }
});
sendMsg(chatId, 'De score klopt niet. Wat is de juiste score?\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 3-6 10-5`');
}
// Match accept/reject
if (data.startsWith('match_accept_') || data.startsWith('match_reject_')) {
const parts = data.split('_');
const action = parts[1];
const matchId = parseInt(parts[2]);
try {
const players = await api('/players');
if (!Array.isArray(players)) throw new Error('Invalid players response');
const player = players.find(p => p.telegram_id == chatId);
if (!player) return;
const response = action === 'accept' ? 'accepted' : 'rejected';
const updated = await api('/matches/' + matchId + '/respond', 'POST', {
player_id: player.id,
response: response
});
bot.answerCallbackQuery(query.id, { text: response === 'accepted' ? 'Aanwezig!' : 'Afgewezen' }).catch(() => {});
// Bei match bevestigd: stuur DM naar alle spelers met boeker info
if (updated && updated.status === 'confirmed') {
const bookerName = updated.booker_name || 'Iemand';
const allNames = await getPlayerNames(updated.players || []);
for (const pid of (updated.players || [])) {
const p = players.find(pl => pl.id === pid);
if (p && p.telegram_id) {
var msg = '';
if (pid === updated.booker_id) {
msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\nJIJ moet de baan boeken! Regel het op tijd.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.';
} else {
msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\n' + bookerName + ' regelt de baanreservering.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.';
}
sendMsg(p.telegram_id, msg);
}
}
}
// Bij afwijzing: stuur DM naar andere spelers
if (response === 'rejected' && updated && updated.status === 'cancelled') {
const rejectName = player.name;
for (const pid of (updated.players || [])) {
if (pid === player.id) continue;
const p = players.find(pl => pl.id === pid);
if (p && p.telegram_id) {
sendMsg(p.telegram_id,
rejectName + ' heeft afgezegd voor ' + updated.date + ' ' + updated.start + '.\n\nNiet genoeg spelers meer. Stuur /match voor een nieuwe poging.'
);
}
}
// Plan score reminder na eindtijd + 5 min
if (updated.date && updated.end) {
const now = new Date();
const [endH, endM] = updated.end.split(':').map(Number);
const matchEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endH, endM);
const delay = Math.max(0, matchEnd.getTime() - now.getTime() + 5 * 60 * 1000);
setTimeout(() => {
const playerIds = updated.players || [];
for (const pid of playerIds) {
const p = players.find(pl => pl.id === pid);
if (p && p.telegram_id) {
sendMsg(p.telegram_id, '📝 **Wedstrijd is afgelopen!**\n\nStuur /score om de uitslag in te vullen.\n\nVoorbeeld: /score 6-4 7-5', { parse_mode: 'Markdown' }).catch(() => {});
}
}
}, delay);
log('Score reminder gepland voor match ' + matchId + ' over ' + Math.round(delay/60000) + ' min');
}
}
bot.editMessageText('Je hebt gereageerd: ' + response, {
chat_id: chatId,
message_id: query.message.message_id
}).catch(err => logError('editMessageText error:', err.message));
} catch (e) {
logError('match response error:', e.message);
bot.answerCallbackQuery(query.id, { text: 'Fout' }).catch(() => {});
}
}
});
// ─── Matching algorithm ───
async function runMatching() {
const players = await api('/players');
if (!Array.isArray(players)) throw new Error('Invalid players');
const matches = await api('/matches');
const today = new Date();
const dayNames = ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag'];
const daySlots = {};
for (let d = 0; d < 7; d++) {
const date = new Date(today);
date.setDate(today.getDate() + d);
const dayName = dayNames[date.getDay()];
if (!daySlots[dayName]) daySlots[dayName] = {};
for (const p of players) {
const avail = p.availability || {};
const slots = avail[dayName] || [];
if (slots.length === 0) continue;
const dateStr = new Date(today).toISOString().split('T')[0];
const rejectedSlots = p.rejected_slots || {};
const toMin = s => { const [h, m] = s.split(':').map(Number); return h * 60 + m; };
const availSlots = (avail[dayName] || []).sort((a, b) => toMin(a.start) - toMin(b.start));
if (availSlots.length === 0) continue;
let blockStart = toMin(availSlots[0].start);
let blockEnd = toMin(availSlots[0].end);
for (let si = 1; si < availSlots.length; si++) {
const s = availSlots[si];
if (toMin(s.start) === blockEnd) {
blockEnd = toMin(s.end);
} else {
blockEnd = Math.max(blockEnd, toMin(s.end));
}
}
const availStart = toMin(availSlots[0].start);
const availEnd = toMin(availSlots[availSlots.length - 1].end);
for (let t = availStart; t + 90 <= availEnd; t += 30) {
const blockStart = String(Math.floor(t / 60)).padStart(2, '0') + ':' + String(t % 60).padStart(2, '0');
const blockEnd = String(Math.floor((t + 90) / 60)).padStart(2, '0') + ':' + String((t + 90) % 60).padStart(2, '0');
const rejectKey = dateStr + '_' + blockStart;
if (rejectedSlots[rejectKey]) continue;
const key = dayName + '_' + blockStart + '_' + blockEnd;
if (!daySlots[dayName][key]) daySlots[dayName][key] = [];
daySlots[dayName][key].push(p);
}
}
}
for (const [day, slots] of Object.entries(daySlots)) {
for (const [key, slotPlayers] of Object.entries(slots)) {
if (slotPlayers.length >= 4) {
const parts = key.split('_');
const start = parts[1], end = parts[2];
const dateStr = new Date(today).toISOString().split('T')[0];
const alreadyMatched = (matches || []).some(m =>
(m.date === dateStr || m.day === day) && m.start === start
);
if (alreadyMatched) continue;
const match = await api('/matches', 'POST', {
day, start, end,
players: slotPlayers.map(p => p.id),
telegram_ids: slotPlayers.filter(p => p.telegram_id).map(p => p.telegram_id)
});
for (const p of slotPlayers) {
if (p.telegram_id) {
sendMsg(p.telegram_id,
'🎾 **Match voorgesteld!**\n\n📅 ' + day + '\n⏰ ' + start + '-' + end + '\n👥 ' + slotPlayers.map(x => x.name).join(', ') + '\n\nReageer met /matches of stuur ✅/❌',
{ parse_mode: 'Markdown' }
);
}
}
return { day, start, end, players: slotPlayers, match };
}
}
}
return null;
}
// ─── New chat members welcome ───
bot.on('new_chat_members', async (msg) => {
const chatId = msg.chat.id;
const botId = parseInt(TOKEN.split(':')[0]);
const isBot = msg.new_chat_members.some(m => m.id === botId || m.is_bot);
if (isBot) {
sendMsg(chatId, '🏓 **Welkom bij Padel Matcher!**\n\nIk help jullie met het vinden van matches en het bijhouden van scores.\n\n**Wat nu?**\n1️⃣ Stuur /setgroup om deze groep in te stellen\n2️⃣ Iedereen registreert via /start in DM met mij\n3️⃣ Stel beschikbaarheid in via: https://padel.iamdoingthings.com\n4️⃣ Stuur /match om te checken of er 4 spelers beschikbaar zijn\n\nCommands:\n/wie — wie is er geregistreerd\n/match — zoek een match\n/setgroup — zet notificaties aan voor deze groep',
{ parse_mode: 'Markdown', disable_web_page_preview: true }
);
} else {
const names = msg.new_chat_members.map(m => m.first_name || m.username || 'Speler').join(', ');
sendMsg(chatId, '🎾 Welkom ' + names + '!\n\nRegistreer via /start in DM met @padel_nl_bot en ontvang een PIN.\nDaarna kun je beschikbaarheid opgeven op: https://padel.iamdoingthings.com',
{ disable_web_page_preview: true }
);
}
});
// ─── Bot command menu instellen ───
bot.setMyCommands([
{ command: 'start', description: 'Registreren / PIN ophalen' },
{ command: 'wie', description: 'Wie is er geregistreerd' },
{ command: 'match', description: 'Zoek een match' },
{ command: 'setgroup', description: 'Stel deze groep in voor notificaties' },
{ command: 'ping', description: 'Check of de bot leeft' },
]).catch(e => logError('Set commands error:', e.message));
// ─── Polling error handler ───
bot.on('polling_error', (err) => {
logError('Polling error:', err.message);
});
log('🤖 Padel Bot started, polling...');
// ─── Auto-match HTTP endpoint (for cron) ───
const httpServer = http.createServer(async (req, res) => {
if (req.url === '/match-check' && req.method === 'POST') {
try {
const result = await runMatching();
if (result) {
const target = process.env.GROUP_CHAT_ID;
if (target) {
const names = result.players.map(p => p.name).join(', ');
sendMsg(Number(target),
'🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌',
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id },
{ text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }]
]
}
}
);
}
for (const p of result.players) {
if (p.telegram_id) {
sendMsg(p.telegram_id,
'🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches',
{ parse_mode: 'Markdown' }
);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, match: result.match.id }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, match: null }));
}
} catch (e) {
logError('Match-check HTTP error:', e.message);
res.writeHead(500);
res.end(JSON.stringify({ error: e.message }));
}
} else {
res.writeHead(404);
res.end('not found');
}
});
httpServer.listen(PORT, () => {
log('Match-check HTTP server on port ' + PORT);
});
// ─── Auto-match elke 30 min ───
setInterval(async () => {
try {
const result = await runMatching();
if (result) {
log('Auto-match found:', result.day, result.start);
const target = process.env.GROUP_CHAT_ID;
if (target) {
const names = result.players.map(p => p.name).join(', ');
sendMsg(Number(target),
'🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌',
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id },
{ text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }]
]
}
}
);
}
for (const p of result.players) {
if (p.telegram_id) {
sendMsg(p.telegram_id,
'🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches',
{ parse_mode: 'Markdown' }
);
}
}
}
} catch (e) {
logError('Auto-match error:', e.message);
}
}, 30 * 60 * 1000);