2285 lines
91 KiB
JavaScript
2285 lines
91 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');
|
|
|
|
// Alleen completed matches tellen (confirmed = nog niet gespeeld)
|
|
const playerMatches = matches.filter(m =>
|
|
m.players && m.players.some(pid => pid == player.id) &&
|
|
m.status === 'completed'
|
|
);
|
|
|
|
// Win/loss/draw obv sets: score heeft team1 (winnaar) en team2 (verliezer),
|
|
// of bij gelijk aantal sets (1-1) is het een draw
|
|
let won = 0, lost = 0, draw = 0;
|
|
for (const m of playerMatches) {
|
|
if (!m.score || !m.score.team1 || !m.score.team2) continue;
|
|
const inTeam1 = (m.score.team1 || []).some(pid => pid == player.id);
|
|
const inTeam2 = (m.score.team2 || []).some(pid => pid == player.id);
|
|
// team1 = winnende team, team2 = verliezende team (zie submitScore logic)
|
|
if (inTeam1) won++;
|
|
else if (inTeam2) lost++;
|
|
else draw++; // speler zit niet in winning/losing team (unlikely)
|
|
}
|
|
const played = playerMatches.length;
|
|
|
|
const sessions = await api('/sessions') || [];
|
|
const playerSessions = (sessions || []).filter(s =>
|
|
s.players && s.players.some(pid => pid == player.id || pid === player.id)
|
|
);
|
|
const totalHours = playerSessions.reduce((sum, s) => {
|
|
const start = s.start || (s.data || {}).start || "00:00";
|
|
const end = s.end || (s.data || {}).end || "00:00";
|
|
const [sh, sm] = start.split(":").map(Number);
|
|
const [eh, em] = end.split(":").map(Number);
|
|
return sum + ((eh + em / 60) - (sh + sm / 60));
|
|
}, 0);
|
|
|
|
const level = player.level || 'onbekend';
|
|
const position = player.position || 'onbekend';
|
|
|
|
let msg = '📊 **Jouw Statistieken**\n\n';
|
|
msg += '👤 ' + player.name + '\n';
|
|
msg += '🎯 Niveau: ' + level + ' | Positie: ' + position + '\n';
|
|
msg += '🔑 PIN: `' + player.pin + '`\n\n';
|
|
msg += '🎾 Wedstrijden: ' + played + ' (' + won + '⚡ win / ' + lost + '❌ verlies' + (draw > 0 ? ' / ' + draw + '🤝 draw' : '') + ')\n';
|
|
msg += '⏱️ Totaal uren: ' + Math.round(totalHours * 10) / 10 + 'h\n';
|
|
msg += '\n[Wijzig profiel](https://padel.iamdoingthings.com) — je PIN is je inlogcode.';
|
|
|
|
sendMsg(chatId, msg, { parse_mode: 'Markdown' });
|
|
} catch (e) {
|
|
logError('/mijn error:', e.message);
|
|
sendMsg(chatId, '❌ Kon stats niet laden. Probeer later opnieuw.');
|
|
}
|
|
});
|
|
|
|
// /matches — openstaande matches
|
|
bot.onText(/\/matches(@padel_nl_bot)?/, async (msg) => {
|
|
const chatId = msg.chat.id;
|
|
try {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) {
|
|
throw new Error('Invalid response from API');
|
|
}
|
|
const player = players.find(p => p.telegram_id == chatId);
|
|
if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start');
|
|
|
|
const matches = await api('/matches');
|
|
if (!Array.isArray(matches)) {
|
|
throw new Error('Invalid matches response');
|
|
}
|
|
const myMatches = matches.filter(m => m.players && m.players.some(pid => pid == player.id) && m.status === 'proposed');
|
|
|
|
if (myMatches.length === 0) {
|
|
return sendMsg(chatId, 'Geen openstaande matches.');
|
|
}
|
|
|
|
for (const m of myMatches) {
|
|
const names = (m.players || []).map(id => {
|
|
const p = players.find(pl => pl.id === id);
|
|
return p ? p.name : 'Onbekend';
|
|
});
|
|
const resp = m.responses?.[player.id] || 'pending';
|
|
|
|
const keyboard = [
|
|
[{ text: '✅ Accepteren', callback_data: 'match_accept_' + m.id },
|
|
{ text: '❌ Negeren', callback_data: 'match_reject_' + m.id }]
|
|
];
|
|
|
|
await sendMsg(chatId,
|
|
'**' + m.day + ' ' + m.date + '** — ' + m.start + '-' + m.end + '\n' + names.join(', ') + '\n\nJouw status: ' + resp,
|
|
{ parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } });
|
|
}
|
|
} catch (e) {
|
|
logError('/matches error:', e.message);
|
|
sendMsg(chatId, 'Fout: ' + e.message);
|
|
}
|
|
});
|
|
|
|
// /ping — health check
|
|
bot.onText(/\/ping(@padel_nl_bot)?/, (msg) => {
|
|
sendMsg(msg.chat.id, '🏓 Pong! Bot is live.');
|
|
});
|
|
|
|
// ─── /feedback — stuur feedback naar beheerder ───
|
|
const OWNER_TELEGRAM_ID = 1231195086; // Melvin
|
|
|
|
bot.onText(/\/feedback(@padel_nl_bot)?(\s+(.+))?/, async (msg, match) => {
|
|
const chatId = msg.chat.id;
|
|
const text = match ? match[3] : null;
|
|
|
|
if (!text || !text.trim()) {
|
|
// Geen tekst meegegeven — vraag om feedback
|
|
return sendMsg(chatId,
|
|
'📝 **Feedback**\n\n' +
|
|
'Heb je een idee, bug of suggestie? Stuur het direct naar de beheerder!\n\n' +
|
|
'Gebruik: /feedback [je bericht]\n' +
|
|
'Voorbeeld: /feedback Het werkt top, maar ik mis een donker thema',
|
|
{ parse_mode: 'Markdown' }
|
|
);
|
|
}
|
|
|
|
const feedback = text.trim();
|
|
const senderName = msg.from.first_name || 'Onbekend';
|
|
const senderUsername = msg.from.username ? '@' + msg.from.username : '';
|
|
|
|
// Stuur naar de beheerder
|
|
const fbMsg = '📬 **Nieuwe feedback**\n\n' +
|
|
'Van: ' + senderName + ' ' + senderUsername + '\n' +
|
|
'Chat: ' + chatId + '\n\n' +
|
|
'---\n' +
|
|
feedback;
|
|
|
|
await sendMsg(OWNER_TELEGRAM_ID, fbMsg, { parse_mode: 'Markdown' });
|
|
|
|
// Bevestig naar de verzender
|
|
await sendMsg(chatId, '✅ Feedback ontvangen! Bedankt voor het meedenken.');
|
|
|
|
log('Feedback van', senderName, chatId, ':', feedback.slice(0, 100));
|
|
});
|
|
|
|
// /setgroup — stel notificatie groep in
|
|
bot.onText(/\/setgroup(@padel_nl_bot)?/, async (msg) => {
|
|
if (msg.chat.type === 'group' || msg.chat.type === 'supergroup') {
|
|
process.env.GROUP_CHAT_ID = String(msg.chat.id);
|
|
sendMsg(msg.chat.id, '✅ Deze groep is ingesteld voor match notificaties!');
|
|
} else {
|
|
sendMsg(msg.chat.id, 'Voeg me eerst toe aan een groep en stuur /setgroup daar.');
|
|
}
|
|
});
|
|
|
|
// /wie — who is registered
|
|
bot.onText(/\/wie(@padel_nl_bot)?/, async (msg) => {
|
|
try {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) throw new Error('Invalid response');
|
|
const list = players.map(p => '• ' + p.name + ' (level ' + (p.level || '?') + ', ' + (p.position || '?') + ')').join('\n');
|
|
sendMsg(msg.chat.id, '🏓 Geregistreerde spelers (' + players.length + '):\n\n' + list);
|
|
} catch (e) {
|
|
sendMsg(msg.chat.id, 'Fout: ' + e.message);
|
|
}
|
|
});
|
|
|
|
// --- /nu --- supersnel beschikbaar doorgeven ---
|
|
// /nu = nu, /nu 18:00, /nu di, /nu di 18:00
|
|
bot.onText(/\/nu(@padel_nl_bot)?(\s+(.+))?/, async (msg, match) => {
|
|
const chatId = msg.chat.id;
|
|
if (!isDM(msg)) return; // alleen DM
|
|
const rest = match ? match[3] : null;
|
|
|
|
try {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) throw new Error('Invalid players');
|
|
const player = players.find(p => p.telegram_id == chatId);
|
|
if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start');
|
|
|
|
const today = new Date();
|
|
const dayNames = ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag'];
|
|
const dagMap = { ma:1, di:2, wo:3, do:4, vr:5, za:6, zo:0, maandag:1, dinsdag:2, woensdag:3, donderdag:4, vrijdag:5, zaterdag:6, zondag:0 };
|
|
|
|
let targetDay = dayNames[today.getDay()];
|
|
let targetStart = null;
|
|
|
|
if (rest && rest.trim()) {
|
|
const parts = rest.trim().split(/\s+/);
|
|
const first = parts[0].toLowerCase();
|
|
if (dagMap[first] !== undefined) {
|
|
const diff = (dagMap[first] - today.getDay() + 7) % 7;
|
|
const d = new Date(today);
|
|
d.setDate(today.getDate() + diff);
|
|
targetDay = dayNames[d.getDay()];
|
|
if (parts[1]) targetStart = parts[1];
|
|
} else if (parts[0].match(/^\d{2}:\d{2}$/)) {
|
|
targetStart = parts[0];
|
|
}
|
|
}
|
|
|
|
// Bepaal starttijd
|
|
if (!targetStart) {
|
|
const now = today.getHours() * 60 + today.getMinutes();
|
|
let nextSlot = Math.ceil(now / 30) * 30;
|
|
targetStart = String(Math.floor(nextSlot / 60)).padStart(2,'0') + ':' + String(nextSlot % 60).padStart(2,'0');
|
|
if (nextSlot >= 23*60) {
|
|
const tomorrow = new Date(today);
|
|
tomorrow.setDate(today.getDate() + 1);
|
|
targetDay = dayNames[tomorrow.getDay()];
|
|
targetStart = '08:00';
|
|
}
|
|
}
|
|
|
|
if (!targetStart || !targetStart.match(/^\d{2}:\d{2}$/)) {
|
|
return sendMsg(chatId, 'Gebruik: /nu, /nu 18:00, /nu di, /nu di 18:00');
|
|
}
|
|
|
|
const [sh, sm] = targetStart.split(':').map(Number);
|
|
const totalMin = sh * 60 + sm + 90;
|
|
let targetEnd = String(Math.floor(totalMin / 60) % 24).padStart(2,'0') + ':' + String(totalMin % 60).padStart(2,'0');
|
|
|
|
// Als eindtijd over middernacht gaat, dag doorschuiven
|
|
let endDay = targetDay;
|
|
if (totalMin >= 24 * 60) {
|
|
const dayNames = ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag'];
|
|
const nextIdx = (dayNames.indexOf(targetDay) + 1) % 7;
|
|
endDay = dayNames[nextIdx];
|
|
targetEnd = String(Math.floor(totalMin / 60) % 24).padStart(2,'0') + ':' + String(totalMin % 60).padStart(2,'0');
|
|
}
|
|
|
|
const avail = player.availability || {};
|
|
if (!avail[targetDay]) avail[targetDay] = [];
|
|
const existingIdx = avail[targetDay].findIndex(s => s.start === targetStart);
|
|
if (existingIdx >= 0) {
|
|
return sendMsg(chatId, 'Je staat al geregistreerd voor ' + targetDay + ' ' + targetStart + '-' + targetEnd);
|
|
}
|
|
avail[targetDay].push({ start: targetStart, end: targetEnd, duration: 90 });
|
|
|
|
await api('PUT', '/players/' + player.id + '/availability', { availability: avail, avail_mode: player.avail_mode || 'flex' });
|
|
|
|
const otherPlayers = players.filter(p => p.id !== player.id && p.telegram_id);
|
|
const alsoAvail = otherPlayers.filter(p => {
|
|
const pa = p.availability || {};
|
|
const pSlots = pa[targetDay] || [];
|
|
return pSlots.some(s => s.start === targetStart);
|
|
});
|
|
|
|
let msg = '\uD83D\uDFE2 **Je staat genoteerd!**\n';
|
|
if (endDay !== targetDay) {
|
|
msg += '\uD83D\uDCC5 ' + targetDay + ' ' + targetStart + ' - ' + endDay + ' ' + targetEnd + '\n\n';
|
|
} else {
|
|
msg += '\uD83D\uDCC5 ' + targetDay + ' ' + targetStart + '-' + targetEnd + '\n\n';
|
|
}
|
|
if (alsoAvail.length > 0) {
|
|
msg += 'Ook beschikbaar:\n' + alsoAvail.map(p => '\u2022 ' + p.name).join('\n');
|
|
msg += '\n\nStuur /match om een match te zoeken!';
|
|
} else {
|
|
msg += 'Nog niemand anders beschikbaar op deze tijd.';
|
|
}
|
|
|
|
await sendMsg(chatId, msg, { parse_mode: 'Markdown' });
|
|
} catch (e) {
|
|
logError('/nu error:', e.message);
|
|
sendMsg(chatId, 'Fout: ' + e.message);
|
|
}
|
|
});
|
|
|
|
// --- /beschikbaar --- beschikbaarheid beheren ---
|
|
bot.onText(/\/beschikbaar(@padel_nl_bot)?/, async (msg) => {
|
|
const chatId = msg.chat.id;
|
|
if (!isDM(msg)) return; // alleen DM
|
|
|
|
try {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) throw new Error('Invalid players');
|
|
const player = players.find(p => p.telegram_id == chatId);
|
|
if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start');
|
|
|
|
const avail = player.availability || {};
|
|
const dayNames = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag'];
|
|
const dayShort = ['ma','di','wo','do','vr','za','zo'];
|
|
|
|
const hasAvail = dayNames.filter(d => avail[d] && avail[d].length > 0);
|
|
|
|
const temp = player.availability_temp || null;
|
|
const tempKeys = temp ? Object.keys(temp) : [];
|
|
|
|
let msg = '\uD83D\uDCC5 **Jouw beschikbaarheid**\n\n';
|
|
if (hasAvail.length === 0 && tempKeys.length === 0) {
|
|
msg += 'Nog geen beschikbaarheid ingesteld.\n';
|
|
} else {
|
|
if (hasAvail.length > 0) {
|
|
msg += '_Vaste dagen:_\n';
|
|
for (const d of hasAvail) {
|
|
const times = avail[d].map(s => s.start + '-' + s.end).join(', ');
|
|
const idx = dayNames.indexOf(d);
|
|
msg += '\uD83D\uDFE2 ' + dayShort[idx] + ' ' + times + '\n';
|
|
}
|
|
}
|
|
if (tempKeys.length > 0) {
|
|
msg += '\n_Tijdelijk:_\n';
|
|
for (const wk of tempKeys) {
|
|
const weekAvail = temp[wk];
|
|
const dayShort2 = ['ma','di','wo','do','vr','za','zo'];
|
|
const parts = [];
|
|
for (const [d, slots] of Object.entries(weekAvail)) {
|
|
if (Array.isArray(slots) && slots.length > 0) {
|
|
parts.push(dayShort2[dayNames.indexOf(d)] + ': ' + slots.map(s => s.start + '-' + s.end).join(', '));
|
|
}
|
|
}
|
|
if (parts.length > 0) msg += '\uD83D\uDCC5 ' + wk + ': ' + parts.join(', ') + '\n';
|
|
}
|
|
}
|
|
}
|
|
|
|
msg += '\nWat wil je doen?';
|
|
|
|
const keyboard = [
|
|
[{ text: '\u2795 Vaste dag', callback_data: 'avail_add_weekly' }],
|
|
[{ text: '\uD83D\uDCC5 Eenmalig', callback_data: 'avail_add_once' }],
|
|
[{ text: '\uD83D\uDC40 Mijn overzicht', callback_data: 'avail_view' }],
|
|
[{ text: '\u274C Wissen', callback_data: 'avail_clear' }],
|
|
];
|
|
|
|
await sendMsg(chatId, msg, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } });
|
|
} catch (e) {
|
|
logError('/beschikbaar error:', e.message);
|
|
sendMsg(chatId, 'Fout: ' + e.message);
|
|
}
|
|
});
|
|
|
|
|
|
// /match — zoek een match
|
|
bot.onText(/\/match(@padel_nl_bot)?/, async (msg) => {
|
|
const targetChat = msg.chat.id;
|
|
try {
|
|
const result = await runMatching();
|
|
if (!result) {
|
|
sendMsg(targetChat, 'Geen match gevonden voor de komende 7 dagen.');
|
|
return;
|
|
}
|
|
const names = result.players.map(p => p.name).join(', ');
|
|
const t1 = (result.proposedTeam1 || []).map(p => p.name).join(' + ');
|
|
const t2 = (result.proposedTeam2 || []).map(p => p.name).join(' + ');
|
|
const msgText = '🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n\n🏆 **Teams:**\n🟡 ' + t1 + '\n🔵 ' + t2 + '\n\nReageer met ✅ of ❌';
|
|
const opts = {
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id },
|
|
{ text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }]
|
|
]
|
|
}
|
|
};
|
|
|
|
sendMsg(targetChat, msgText, opts);
|
|
|
|
for (const p of result.players) {
|
|
if (p.telegram_id) {
|
|
sendMsg(p.telegram_id,
|
|
'🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer: /matches',
|
|
{ parse_mode: 'Markdown' }
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
sendMsg(targetChat, 'Fout: ' + e.message);
|
|
}
|
|
});
|
|
|
|
// ─── Score state ───
|
|
// Per chatId: { step: 'awaiting_score'|'awaiting_teammate'|'awaiting_verification', matchId, submitted_by, team1, team2, sets, scoreStr }
|
|
|
|
// ─── /score — voer score in (DM only) ───
|
|
bot.onText(/\/score(@padel_nl_bot)?(\s+(.+))?/, async (msg, regexMatch) => {
|
|
const chatId = msg.chat.id;
|
|
const chatType = msg.chat.type;
|
|
|
|
// Alleen in DM
|
|
if (chatType !== 'private') {
|
|
return sendMsg(chatId, '📝 Score invoeren alleen in DM. Stuur het commando naar @padel_nl_bot.');
|
|
}
|
|
|
|
try {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) throw new Error('Invalid players response');
|
|
const player = players.find(p => p.telegram_id == chatId);
|
|
if (!player) return sendMsg(chatId, 'Niet geregistreerd. Stuur /start');
|
|
|
|
// Vind completed/confirmed matches waar deze speler in zit
|
|
const matches = await api('/matches');
|
|
if (!Array.isArray(matches)) throw new Error('Invalid matches response');
|
|
|
|
// Filter: matches waar speler in zit, status=confirmed of completed, nog geen confirmed score
|
|
const openMatches = matches.filter(m =>
|
|
m.players && m.players.some(pid => pid == player.id) &&
|
|
(m.status === 'confirmed' || m.status === 'completed') &&
|
|
(!m.score || m.score.status !== 'confirmed')
|
|
);
|
|
|
|
if (openMatches.length === 0) {
|
|
return sendMsg(chatId, 'Geen openstaande scores om in te vullen. Je hebt geen recente wedstrijden zonder score.');
|
|
}
|
|
|
|
// Als er meerdere zijn, laat kiezen
|
|
if (openMatches.length > 1) {
|
|
const keyboard = openMatches.map(m => {
|
|
const names = (m.players || []).map(pid => {
|
|
const p = players.find(pl => pl.id === pid);
|
|
return p ? p.name : '?';
|
|
}).join(', ');
|
|
return [{ text: m.date + ' ' + m.start + ' — ' + names, callback_data: 'score_match_' + m.id }];
|
|
});
|
|
return sendMsg(chatId, 'Voor welke wedstrijd wil je de score invullen?', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
|
|
// Eén match — ga direct naar score invoer
|
|
const selectedMatch = openMatches[0];
|
|
|
|
// Zit er al een score bij? Check of deze speler al heeft ingediend
|
|
if (selectedMatch.score && selectedMatch.score.status === 'pending') {
|
|
// Al ingediend, wacht op verificatie
|
|
return sendMsg(chatId, 'Er is al een score ingediend voor deze wedstrijd. Wacht tot een tegenstander verifieert.');
|
|
}
|
|
|
|
// regexMatch[3] = alles na /score (de inline parameters)
|
|
const rest = regexMatch ? regexMatch[3] : null;
|
|
if (rest && rest.trim()) {
|
|
// /score 6-3 6-4 [met X]
|
|
await handleScoreInput(chatId, player, selectedMatch, rest.trim(), players);
|
|
} else {
|
|
// /score zonder parameters — vraag sets
|
|
setUserState(chatId, {
|
|
step: 'awaiting_score',
|
|
matchId: selectedMatch.id,
|
|
player: player
|
|
});
|
|
sendMsg(chatId, '📋 **Wedstrijd:** ' + (selectedMatch.date || '?') + ' ' + (selectedMatch.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5` (2 sets)\n`4-6 6-3 10-7` (3 sets / super tiebreak)', { parse_mode: 'Markdown' });
|
|
}
|
|
} catch (e) {
|
|
logError('/score error:', e.message);
|
|
sendMsg(chatId, 'Fout: ' + e.message);
|
|
}
|
|
});
|
|
|
|
async function handleScoreInput(chatId, player, match, input, players) {
|
|
// Parse: "6-3 6-4" of "6-3 6-4 met Thomas"
|
|
let setsStr, teammateName;
|
|
|
|
const metMatch = input.match(/^(.+?)\s+met\s+(.+)$/i);
|
|
if (metMatch) {
|
|
setsStr = metMatch[1].trim();
|
|
teammateName = metMatch[2].trim();
|
|
} else {
|
|
setsStr = input.trim();
|
|
teammateName = null;
|
|
}
|
|
|
|
// Parse sets: "6-3 6-4" → [{team1:6, team2:3}, {team1:6, team2:4}]
|
|
const parts = setsStr.split(/[\s,]+/).filter(Boolean);
|
|
const sets = [];
|
|
const setStrParts = [];
|
|
|
|
for (const p of parts) {
|
|
const sp = p.split('-');
|
|
if (sp.length !== 2) {
|
|
return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik formaat zoals 6-4 7-5');
|
|
}
|
|
const s1 = parseInt(sp[0], 10);
|
|
const s2 = parseInt(sp[1], 10);
|
|
if (isNaN(s1) || isNaN(s2)) {
|
|
return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik getallen zoals 6-4');
|
|
}
|
|
sets.push({ team1: s1, team2: s2 });
|
|
setStrParts.push(p);
|
|
}
|
|
|
|
if (sets.length < 2) {
|
|
return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in. Bijvoorbeeld: 6-4 7-5. Of bij 3 sets / super tiebreak: 4-6 6-3 10-7');
|
|
}
|
|
|
|
const scoreStr = setStrParts.join(', ');
|
|
|
|
// Bepaal winnaar op basis van sets
|
|
let team1Wins = 0, team2Wins = 0;
|
|
for (const s of sets) {
|
|
if (s.team1 > s.team2) team1Wins++;
|
|
else team2Wins++;
|
|
}
|
|
|
|
if (team1Wins === team2Wins) {
|
|
return sendMsg(chatId, '⚠️ Gelijkspel in sets. Bij padel kan dat niet — wie heeft de wedstrijd gewonnen?');
|
|
}
|
|
|
|
const otherPlayerIds = (match.players || []).filter(pid => pid !== player.id);
|
|
const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean);
|
|
|
|
if (teammateName) {
|
|
// Zoek teamgenoot op naam
|
|
const teammate = otherPlayers.find(p => p.name.toLowerCase() === teammateName.toLowerCase());
|
|
if (!teammate) {
|
|
return sendMsg(chatId, '⚠️ Kan "' + teammateName + '" niet vinden in deze wedstrijd. Deelnemers: ' + otherPlayers.map(p => p.name).join(', '));
|
|
}
|
|
|
|
const team1 = [player.id, teammate.id];
|
|
const team2 = otherPlayerIds.filter(pid => !team1.some(t => t == pid));
|
|
|
|
await submitScore(chatId, player, match, team1, team2, sets, scoreStr, players);
|
|
} else {
|
|
// Vraag met wie
|
|
const keyboard = otherPlayers.map(p =>
|
|
[{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }]
|
|
);
|
|
// Als 3 anderen, ook optie "ik was alleen"? Nee, padel is altijd dubbel
|
|
setUserState(chatId, {
|
|
step: 'awaiting_teammate',
|
|
matchId: match.id,
|
|
player: player,
|
|
sets: sets,
|
|
scoreStr: scoreStr
|
|
});
|
|
sendMsg(chatId, 'Met wie vormde je een team?', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
}
|
|
|
|
async function submitScore(chatId, player, match, team1, team2, sets, scoreStr, players) {
|
|
try {
|
|
const matchId = match.id;
|
|
|
|
// Double-submit guard: check of er al een pending score is
|
|
try {
|
|
const freshMatches = await api('/matches');
|
|
const freshMatch = Array.isArray(freshMatches) ? freshMatches.find(m => m.id === matchId) : null;
|
|
if (freshMatch && freshMatch.score && freshMatch.score.status === 'pending') {
|
|
sendMsg(chatId, 'Er is al een score ingediend voor deze wedstrijd. Wacht tot verificatie.');
|
|
return false;
|
|
}
|
|
} catch(e) {
|
|
// Als check faalt, ga door — beter een dubbele score dan een false positive
|
|
logError('submitScore double-submit check error:', e.message);
|
|
}
|
|
|
|
// Bepaal winnaar (team1 of team2)
|
|
let team1Wins = 0, team2Wins = 0;
|
|
for (const s of sets) {
|
|
if (s.team1 > s.team2) team1Wins++;
|
|
else team2Wins++;
|
|
}
|
|
const winningTeam = team1Wins > team2Wins ? team1 : team2;
|
|
const losingTeam = team1Wins > team2Wins ? team2 : team1;
|
|
|
|
// Bepaal welke sets team1/team2 gewonnen hebben (voor weergave)
|
|
const t1 = team1Wins > team2Wins ? team1 : team2; // winnende team
|
|
const t2 = team1Wins > team2Wins ? team2 : team1; // verliezende team
|
|
|
|
const t1Names = t1.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
|
|
const t2Names = t2.map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
|
|
|
|
// Verstuur naar API
|
|
const updated = await api('/matches/' + matchId + '/score', 'POST', {
|
|
team1: t1,
|
|
team2: t2,
|
|
score: scoreStr,
|
|
sets: sets,
|
|
submitted_by: player.id
|
|
});
|
|
|
|
log('Score submitted for match', matchId, 'by', player.id, ':', scoreStr);
|
|
|
|
// Stuur bevestiging naar indienner
|
|
let setLines = sets.map((s, i) => 'Set ' + (i + 1) + ': ' + s.team1 + '-' + s.team2).join('\n');
|
|
await sendMsg(chatId,
|
|
'✅ **Score ingediend!**\n\n' +
|
|
setLines + '\n\n' +
|
|
'🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' +
|
|
'📋 Status: **wacht op verificatie**\n' +
|
|
'Een tegenstander krijgt een bericht om te verifiëren.'
|
|
);
|
|
|
|
// Stuur verificatie-bericht naar 1 tegenstander (niet de indienner)
|
|
// Eerst proberen van losing team, dan van winning team, nooit de submitter zelf
|
|
const losingCandidates = t2.filter(pid => pid !== player.id);
|
|
const winningCandidates = t1.filter(pid => pid !== player.id);
|
|
const verifierId = losingCandidates.length > 0 ? losingCandidates[0]
|
|
: winningCandidates.length > 0 ? winningCandidates[0]
|
|
: null;
|
|
|
|
if (verifierId === null) {
|
|
log('No verifier found for match', matchId, '(all players are submitter?)');
|
|
} else {
|
|
const verifier = players.find(p => p.id === verifierId);
|
|
|
|
if (verifier && verifier.telegram_id) {
|
|
await sendMsg(verifier.telegram_id,
|
|
'📋 **Score verificatie**\n\n' +
|
|
'Wedstrijd: ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\n' +
|
|
setLines + '\n\n' +
|
|
'🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' +
|
|
'Ingevuld door: ' + player.name + '\n\n' +
|
|
'Klopt deze score?',
|
|
{
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, klopt!', callback_data: 'score_verify_' + matchId + '_' + verifierId }],
|
|
[{ text: '❌ Nee, corrigeer', callback_data: 'score_reject_' + matchId + '_' + verifierId }]
|
|
]
|
|
}
|
|
}
|
|
);
|
|
log('Verification sent to', verifier.name, '(tg:', verifier.telegram_id, ')');
|
|
} else {
|
|
log('No verifier found for match', matchId, '(no telegram_id)');
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
logError('submitScore error:', e.message);
|
|
sendMsg(chatId, 'Fout bij opslaan score: ' + e.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Message handler (registratie stappen) ───
|
|
bot.on('message', async (msg) => {
|
|
const chatId = msg.chat.id;
|
|
const text = msg.text;
|
|
if (!text || text.startsWith('/')) return;
|
|
|
|
const state = userState.get(chatId);
|
|
if (!state) return;
|
|
|
|
// ─── Score input (awaiting sets) ───
|
|
if (state.step === 'awaiting_score' || state.step === 'awaiting_score_input') {
|
|
if (!state.player) { userState.delete(chatId); return sendMsg(chatId, 'Session verlopen. Stuur /score opnieuw.'); }
|
|
try {
|
|
const players = await api('/players');
|
|
const matches = await api('/matches');
|
|
const match = matches.find(m => m.id === state.matchId);
|
|
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
|
|
const player = state.player;
|
|
await handleScoreInput(chatId, player, match, text, players);
|
|
} catch(e) {
|
|
logError('score message handler:', e.message);
|
|
sendMsg(chatId, 'Fout: ' + e.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ─── Score correction (rejected by verifier) ───
|
|
if (state.step === 'awaiting_score_correction') {
|
|
try {
|
|
const players = await api('/players');
|
|
const matches = await api('/matches');
|
|
const match = matches.find(m => m.id === state.matchId);
|
|
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
|
|
const parts = text.split(/[\s,]+/).filter(Boolean);
|
|
const sets = [];
|
|
const setStrParts = [];
|
|
for (const p of parts) {
|
|
const sp = p.split('-');
|
|
if (sp.length !== 2) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '". Gebruik 6-4 7-5');
|
|
const s1 = parseInt(sp[0], 10), s2 = parseInt(sp[1], 10);
|
|
if (isNaN(s1) || isNaN(s2)) return sendMsg(chatId, '⚠️ Ongeldige set: "' + p + '"');
|
|
sets.push({ team1: s1, team2: s2 });
|
|
setStrParts.push(p);
|
|
}
|
|
if (sets.length < 2) return sendMsg(chatId, '⚠️ Voer minimaal 2 sets in.');
|
|
const scoreStr = setStrParts.join(', ');
|
|
const verifierPlayer = players.find(p => p.id === state.player.id);
|
|
if (!verifierPlayer) return sendMsg(chatId, 'Speler niet gevonden.');
|
|
const otherPlayerIds = (match.players || []).filter(pid => pid !== state.player.id);
|
|
const otherPlayers = otherPlayerIds.map(pid => players.find(p => p.id === pid)).filter(Boolean);
|
|
userState.set(chatId, {
|
|
step: 'awaiting_teammate_correction',
|
|
matchId: match.id,
|
|
player: verifierPlayer,
|
|
sets: sets,
|
|
scoreStr: scoreStr
|
|
});
|
|
const keyboard = otherPlayers.map(p => [{ text: p.name, callback_data: 'score_teammate_' + match.id + '_' + p.id }]);
|
|
sendMsg(chatId, 'Met wie vormde jij een team?', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
} catch(e) {
|
|
logError('score correction handler:', e.message);
|
|
sendMsg(chatId, 'Fout: ' + e.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state.step === 'register_name') {
|
|
state.data.name = text;
|
|
state.step = 'register_level';
|
|
sendMsg(chatId, 'Mooi, ' + text + '! Wat is je padel level? (1-10)');
|
|
return;
|
|
}
|
|
|
|
if (state.step === 'register_level') {
|
|
const level = parseInt(text);
|
|
if (isNaN(level) || level < 1 || level > 10) {
|
|
return sendMsg(chatId, 'Kies een level tussen 1 en 10.');
|
|
}
|
|
state.data.level = level;
|
|
state.step = 'register_position';
|
|
const keyboard = [
|
|
[{ text: 'Links 🎾', callback_data: 'pos_Links' },
|
|
{ text: 'Rechts 🎾', callback_data: 'pos_Rechts' }],
|
|
[{ text: 'Beide 🎾', callback_data: 'pos_Beide' },
|
|
{ text: 'Onbekend', callback_data: 'pos_Onbekend' }]
|
|
];
|
|
sendMsg(chatId, 'Wat is je voorkeurspositie?', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Callback queries ───
|
|
bot.on('callback_query', async (query) => {
|
|
const chatId = query.message.chat.id;
|
|
const data = query.data;
|
|
const state = userState.get(chatId);
|
|
|
|
// Position selection
|
|
if (data.startsWith('pos_')) {
|
|
const position = data.replace('pos_', '');
|
|
if (state && state.step === 'register_position') {
|
|
state.data.position = position;
|
|
state.data.pin = String(Math.floor(1000 + Math.random() * 9000));
|
|
|
|
try {
|
|
const player = await api('/players', 'POST', {
|
|
name: state.data.name,
|
|
level: state.data.level,
|
|
position: state.data.position,
|
|
telegram_id: chatId,
|
|
pin: state.data.pin,
|
|
});
|
|
userState.delete(chatId);
|
|
sendMsg(chatId, '✅ Geregistreerd!\n\nNaam: ' + state.data.name + '\nLevel: ' + state.data.level + '\nPositie: ' + position + '\nJe PIN: ' + state.data.pin + '\n\n🌐 Ga naar de webpagina om in te loggen:\nhttps://padel.iamdoingthings.com');
|
|
} catch (e) {
|
|
sendMsg(chatId, 'Fout bij registratie: ' + e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Availability — entrypoints from /beschikbaar menu
|
|
if (data === 'avail_view') {
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
const avail = player.availability || {};
|
|
const dayNames = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag'];
|
|
const dayShort = ['ma','di','wo','do','vr','za','zo'];
|
|
const hasAvail = dayNames.filter(d => avail[d] && avail[d].length > 0);
|
|
let msg = '\uD83D\uDCC5 **Jouw beschikbaarheid**\n\n';
|
|
if (hasAvail.length === 0) msg += 'Nog geen beschikbaarheid ingesteld.\n';
|
|
else {
|
|
for (const d of hasAvail) {
|
|
const times = avail[d].map(s => s.start + '-' + s.end).join(', ');
|
|
msg += '\uD83D\uDFE2 ' + dayShort[dayNames.indexOf(d)] + ' ' + times + '\n';
|
|
}
|
|
}
|
|
await sendMsg(chatId, msg, { parse_mode: 'Markdown' });
|
|
} catch(e) { logError('avail_view:', e.message); }
|
|
}
|
|
|
|
if (data === 'avail_clear') {
|
|
// Toon wist-menu: vaste dagen of tijdelijke
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
const avail = player.availability || {};
|
|
const temp = player.availability_temp || null;
|
|
const dayNames = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag'];
|
|
const dayShort = ['ma','di','wo','do','vr','za','zo'];
|
|
const hasFixed = dayNames.filter(d => avail[d] && avail[d].length > 0);
|
|
const tempKeys = temp ? Object.keys(temp) : [];
|
|
|
|
if (hasFixed.length === 0 && tempKeys.length === 0) {
|
|
await sendMsg(chatId, 'Er is niets om te wissen.');
|
|
return;
|
|
}
|
|
|
|
let msg = '\u274C **Wat wil je wissen?**\n\n';
|
|
const keyboard = [];
|
|
|
|
if (hasFixed.length > 0) {
|
|
keyboard.push([{ text: '\uD83D\uDDD1\uFE0F Vaste dagen wissen', callback_data: 'avail_clear_fixed' }]);
|
|
}
|
|
|
|
if (tempKeys.length > 0) {
|
|
keyboard.push([{ text: '\u23F0 Tijdelijke beschikbaarheid wissen', callback_data: 'avail_clear_temp' }]);
|
|
}
|
|
|
|
keyboard.push([{ text: '\u274C Alles wissen', callback_data: 'avail_clear_all' }]);
|
|
keyboard.push([{ text: '\u2190 Terug', callback_data: 'avail_back_menu' }]);
|
|
|
|
await sendMsg(chatId, msg, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } });
|
|
} catch(e) { logError('avail_clear menu:', e.message); }
|
|
}
|
|
|
|
if (data === 'avail_back_menu') {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
const avail = player.availability || {};
|
|
const dayNames = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag'];
|
|
const dayShort = ['ma','di','wo','do','vr','za','zo'];
|
|
const hasAvail = dayNames.filter(d => avail[d] && avail[d].length > 0);
|
|
let msg = '\uD83D\uDCC5 **Jouw beschikbaarheid**\n\n';
|
|
if (hasAvail.length === 0) msg += 'Nog geen beschikbaarheid ingesteld.\n';
|
|
else {
|
|
for (const d of hasAvail) {
|
|
const times = avail[d].map(s => s.start + '-' + s.end).join(', ');
|
|
msg += '\uD83D\uDFE2 ' + dayShort[dayNames.indexOf(d)] + ' ' + times + '\n';
|
|
}
|
|
}
|
|
msg += '\nWat wil je doen?';
|
|
const keyboard = [
|
|
[{ text: '\u2795 Vaste dag', callback_data: 'avail_add_weekly' }],
|
|
[{ text: '\uD83D\uDCC5 Eenmalig', callback_data: 'avail_add_once' }],
|
|
[{ text: '\uD83D\uDC40 Mijn overzicht', callback_data: 'avail_view' }],
|
|
[{ text: '\u274C Wissen', callback_data: 'avail_clear' }],
|
|
];
|
|
await sendMsg(chatId, msg, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } });
|
|
}
|
|
|
|
if (data === 'avail_clear_fixed') {
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
await api('PUT', '/players/' + player.id + '/availability', { availability: {}, avail_mode: 'flex' });
|
|
await sendMsg(chatId, '\u2705 Vaste beschikbaarheid gewist!');
|
|
} catch(e) { logError('avail_clear_fixed:', e.message); }
|
|
}
|
|
|
|
if (data === 'avail_clear_temp') {
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
await api('PUT', '/players/' + player.id + '/availability', { availability_temp: null, avail_mode: player.avail_mode || 'flex' });
|
|
await sendMsg(chatId, '\u2705 Tijdelijke beschikbaarheid gewist!');
|
|
} catch(e) { logError('avail_clear_temp:', e.message); }
|
|
}
|
|
|
|
if (data === 'avail_clear_all') {
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
await api('PUT', '/players/' + player.id + '/availability', { availability: {}, availability_temp: null, avail_mode: 'flex' });
|
|
await sendMsg(chatId, '\u2705 Alle beschikbaarheid gewist!');
|
|
} catch(e) { logError('avail_clear_all:', e.message); }
|
|
}
|
|
|
|
if (data === 'avail_add_weekly') {
|
|
// Direct naar dagkeuze (vaste dagen = wekelijks patroon)
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
const existingAvail = JSON.parse(JSON.stringify(player.availability || {}));
|
|
setUserState(chatId, {
|
|
step: 'avail_day',
|
|
data: { player: player, availability: existingAvail, mode: 'weekly', weekKey: null }
|
|
});
|
|
} catch(e) { logError('avail_add_weekly state:', e.message); }
|
|
|
|
const days = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
|
const keyboard = days.map(d => [{ text: d, callback_data: 'avail_day_' + d }]);
|
|
keyboard.push([{ text: '\u2705 Klaar met instellen', callback_data: 'avail_done' }]);
|
|
sendMsg(chatId, 'Kies een dag om toe te voegen aan je vaste weekschema:', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
|
|
if (data === 'avail_add_once') {
|
|
// Eerst week kiezen
|
|
const now = new Date();
|
|
const currentYear = now.getFullYear();
|
|
// Bereken huidige ISO week
|
|
function isoWeek(d) {
|
|
const d2 = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
const dayNum = d2.getUTCDay() || 7;
|
|
d2.setUTCDate(d2.getUTCDate() + 4 - dayNum);
|
|
const yearStart = new Date(Date.UTC(d2.getUTCFullYear(), 0, 1));
|
|
return Math.ceil((((d2 - yearStart) / 86400000) + 1) / 7);
|
|
}
|
|
const thisWeek = currentYear + '-W' + String(isoWeek(now)).padStart(2, '0');
|
|
const nextWeek = currentYear + '-W' + String(isoWeek(now) + 1).padStart(2, '0');
|
|
const afterWeek = currentYear + '-W' + String(isoWeek(now) + 2).padStart(2, '0');
|
|
|
|
const keyboard = [
|
|
[{ text: '\uD83D\uDCC5 Deze week (' + thisWeek + ')', callback_data: 'avail_week_' + thisWeek }],
|
|
[{ text: '\uD83D\uDCC6 Volgende week (' + nextWeek + ')', callback_data: 'avail_week_' + nextWeek }],
|
|
[{ text: '\u2615 Over 2 weken (' + afterWeek + ')', callback_data: 'avail_week_' + afterWeek }],
|
|
[{ text: '\u2190 Terug', callback_data: 'avail_back_menu' }],
|
|
];
|
|
sendMsg(chatId, 'Welke week is de eenmalige beschikbaarheid?', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
|
|
if (data.startsWith('avail_week_')) {
|
|
const weekKey = data.replace('avail_week_', '');
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!player) return;
|
|
const existingTemp = player.availability_temp || {};
|
|
const weekAvail = existingTemp[weekKey] || {};
|
|
setUserState(chatId, {
|
|
step: 'avail_day',
|
|
data: { player: player, availability: weekAvail, mode: 'flex', weekKey: weekKey, existingTemp: existingTemp }
|
|
});
|
|
} catch(e) { logError('avail_week state:', e.message); }
|
|
|
|
const days = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
|
const keyboard = days.map(d => [{ text: d, callback_data: 'avail_day_' + d }]);
|
|
keyboard.push([{ text: '\u2705 Klaar met instellen', callback_data: 'avail_done_once' }]);
|
|
sendMsg(chatId, 'Kies een dag voor week ' + weekKey + ':', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
|
|
// Availability — day selection
|
|
if (data.startsWith('avail_day_')) {
|
|
const day = data.replace('avail_day_', '');
|
|
// Zet userState voor slot toggle
|
|
const existingState = userState.get(chatId);
|
|
if (!existingState || !existingState.data.player) {
|
|
// Initialiseer state via player lookup
|
|
setUserState(chatId, { step: 'avail_day', data: { availability: {}, player: null } });
|
|
}
|
|
// Zoek player
|
|
if (!userState.get(chatId)?.data?.player) {
|
|
try {
|
|
const players = await api('/players');
|
|
const player = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (player) {
|
|
userState.get(chatId).data.player = player;
|
|
userState.get(chatId).data.availability = JSON.parse(JSON.stringify(player.availability || {}));
|
|
}
|
|
} catch(e) {
|
|
logError('avail_day_ player lookup:', e.message);
|
|
}
|
|
}
|
|
|
|
const slots = [];
|
|
for (let h = 6; h < 23; h++) {
|
|
for (let m = 0; m < 60; m += 30) {
|
|
slots.push(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'));
|
|
}
|
|
}
|
|
slots.push('23:00');
|
|
|
|
const keyboard = [];
|
|
for (let i = 0; i < slots.length - 1; i += 2) {
|
|
const row = [];
|
|
for (let j = i; j < Math.min(i + 2, slots.length - 1); j++) {
|
|
row.push({ text: slots[j] + '-' + slots[j + 1], callback_data: 'avail_slot_' + day + '_' + slots[j] + '_' + slots[j + 1] });
|
|
}
|
|
keyboard.push(row);
|
|
}
|
|
keyboard.push([{ text: '↩️ Terug naar dagen', callback_data: 'avail_back' }]);
|
|
|
|
sendMsg(chatId, 'Beschikbaarheid voor **' + day + '**\nKlik op een tijdsblok om het aan/uit te zetten:', {
|
|
parse_mode: 'Markdown',
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
|
|
// Availability — slot toggle
|
|
if (data.startsWith('avail_slot_')) {
|
|
const remainder = data.slice('avail_slot_'.length); // dag_start_end
|
|
const parts = remainder.split('_');
|
|
const day = parts[0];
|
|
const start = parts[1];
|
|
const end = parts[2];
|
|
|
|
if (!state || !state.data.availability) return;
|
|
|
|
if (!state.data.availability[day]) state.data.availability[day] = [];
|
|
|
|
const idx = state.data.availability[day].findIndex(s => s.start === start);
|
|
if (idx >= 0) {
|
|
state.data.availability[day].splice(idx, 1);
|
|
bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' uit ✅', show_alert: false }).catch(() => {});
|
|
} else {
|
|
state.data.availability[day].push({ start, end, duration: 90 });
|
|
bot.answerCallbackQuery(query.id, { text: start + '-' + end + ' aan ✅', show_alert: false }).catch(() => {});
|
|
}
|
|
|
|
const slots = state.data.availability[day];
|
|
const statusText = slots.length > 0
|
|
? 'Geselecteerd voor ' + day + ': ' + slots.map(s => s.start + '-' + s.end).join(', ')
|
|
: 'Geen blokken geselecteerd voor ' + day;
|
|
|
|
bot.editMessageText('Beschikbaarheid voor **' + day + '**\n' + statusText + '\n\nKlik om aan te passen:', {
|
|
chat_id: chatId,
|
|
message_id: query.message.message_id,
|
|
parse_mode: 'Markdown',
|
|
reply_markup: query.message.reply_markup
|
|
}).catch(err => logError('editMessageText error:', err.message));
|
|
}
|
|
|
|
// Availability — back to day select
|
|
if (data === 'avail_back') {
|
|
userState.set(chatId, { ...state, step: 'avail_day' });
|
|
const days = ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'];
|
|
const keyboard = days.map(d => [{ text: d, callback_data: 'avail_day_' + d }]);
|
|
keyboard.push([{ text: '✅ Klaar met instellen', callback_data: 'avail_done' }]);
|
|
sendMsg(chatId, 'Kies een dag:', {
|
|
reply_markup: { inline_keyboard: keyboard }
|
|
});
|
|
}
|
|
|
|
// Availability — done
|
|
if (data === 'avail_done') {
|
|
if (!state || !state.data.player) {
|
|
return sendMsg(chatId, '⚠️ Sessie verlopen. Stuur /beschikbaar opnieuw.');
|
|
}
|
|
if (state.data.mode === 'flex' && state.data.weekKey) return; // negeren, avail_done_once handelt af
|
|
|
|
try {
|
|
await api('/players/' + state.data.player.id + '/availability', 'PUT', {
|
|
availability: state.data.availability,
|
|
avail_mode: 'weekly'
|
|
});
|
|
userState.delete(chatId);
|
|
sendMsg(chatId, '✅ Vaste beschikbaarheid opgeslagen!');
|
|
} catch (e) {
|
|
sendMsg(chatId, 'Fout bij opslaan: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Availability — done (once / flex for a specific week)
|
|
if (data === 'avail_done_once') {
|
|
if (!state || !state.data.player || state.data.mode !== 'flex' || !state.data.weekKey) {
|
|
return sendMsg(chatId, '⚠️ Sessie verlopen. Stuur /beschikbaar opnieuw.');
|
|
}
|
|
|
|
try {
|
|
const existingTemp = state.data.existingTemp || {};
|
|
existingTemp[state.data.weekKey] = state.data.availability;
|
|
|
|
await api('/players/' + state.data.player.id + '/availability', 'PUT', {
|
|
availability: state.data.player.availability || {},
|
|
avail_mode: 'flex',
|
|
availability_temp: existingTemp
|
|
});
|
|
userState.delete(chatId);
|
|
sendMsg(chatId, '✅ Beschikbaarheid voor week ' + state.data.weekKey + ' opgeslagen!');
|
|
} catch (e) {
|
|
sendMsg(chatId, 'Fout bij opslaan: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Score — kies match
|
|
if (data.startsWith('score_match_')) {
|
|
const matchId = parseInt(data.split('_')[2]);
|
|
if (!state || state.step !== 'awaiting_score') return;
|
|
bot.answerCallbackQuery(query.id).catch(() => {});
|
|
try {
|
|
const players = await api('/players');
|
|
const matches = await api('/matches');
|
|
const match = matches.find(m => m.id === matchId);
|
|
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
|
|
const player = state.player;
|
|
userState.set(chatId, { step: 'awaiting_score_input', matchId: match.id, player: player });
|
|
sendMsg(chatId, '📋 **Wedstrijd:** ' + (match.date || '?') + ' ' + (match.start || '?') + '\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 7-5`\n`4-6 6-3 10-7`');
|
|
} catch(e) { logError('score_match callback:', e.message); }
|
|
}
|
|
|
|
// Score — kies teamgenoot
|
|
if (data.startsWith('score_teammate_')) {
|
|
const parts = data.split('_');
|
|
const matchId = parseInt(parts[2]);
|
|
const teammateId = parseInt(parts[3]);
|
|
|
|
if (!state || (state.step !== 'awaiting_teammate' && state.step !== 'awaiting_teammate_correction')) return;
|
|
bot.answerCallbackQuery(query.id).catch(() => {});
|
|
|
|
try {
|
|
const players = await api('/players');
|
|
const matches = await api('/matches');
|
|
const match = matches.find(m => m.id === matchId);
|
|
if (!match) return sendMsg(chatId, 'Match niet gevonden.');
|
|
|
|
const player = state.player;
|
|
const team1 = [player.id, teammateId];
|
|
const team2 = (match.players || []).filter(pid => !team1.some(t => t == pid));
|
|
await submitScore(chatId, player, match, team1, team2, state.sets, state.scoreStr, players);
|
|
userState.delete(chatId);
|
|
} catch(e) { logError('score_teammate callback:', e.message); }
|
|
}
|
|
|
|
// Score — verifieer
|
|
// SECURITY: alleen de verifier die in het callback_data staat mag verifiëren
|
|
// check via chatId of deze telegram_id matcht met verifierId
|
|
if (data.startsWith('score_verify_')) {
|
|
const parts = data.split('_');
|
|
const matchId = parseInt(parts[2]);
|
|
const verifierId = parseInt(parts[3]);
|
|
|
|
// SECURITY CHECK: wie klikt mag alleen verifiëren als telegram_id matcht
|
|
try {
|
|
const players = await api('/players');
|
|
const clickingPlayer = players.find(p => p.telegram_id == chatId);
|
|
if (!clickingPlayer || clickingPlayer.id !== verifierId) {
|
|
bot.answerCallbackQuery(query.id, { text: 'Je bent niet de verifier van deze score ❌' }).catch(() => {});
|
|
return;
|
|
}
|
|
} catch(e) {
|
|
logError('score_verify security check error:', e.message);
|
|
return;
|
|
}
|
|
|
|
bot.answerCallbackQuery(query.id, { text: 'Score geverifieerd! ✅' }).catch(() => {});
|
|
|
|
try {
|
|
const players = await api('/players');
|
|
const updated = await api('/matches/' + matchId + '/verify', 'POST', { player_id: verifierId });
|
|
|
|
if (updated && updated.score && updated.score.status === 'confirmed') {
|
|
// Score is confirmed!
|
|
const t1Names = (updated.score.team1 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
|
|
const t2Names = (updated.score.team2 || []).map(pid => players.find(p => p.id === pid)?.name || '?').join(' & ');
|
|
const setStr = Array.isArray(updated.score.sets)
|
|
? updated.score.sets.map((s, i) => 'Set ' + (i+1) + ': ' + s.team1 + '-' + s.team2).join(' | ')
|
|
: 'Score: ' + updated.score.score;
|
|
|
|
// Notify all 4 players
|
|
for (const pid of (updated.players || [])) {
|
|
const p = players.find(pl => pl.id === pid);
|
|
if (p && p.telegram_id) {
|
|
let msg = '✅ **Score bevestigd!**\n\n' +
|
|
'Wedstrijd: ' + (updated.date || '?') + ' ' + (updated.start || '?') + '\n' +
|
|
setStr + '\n\n' +
|
|
'🏆 ' + t1Names + ' vs ' + t2Names + '\n\n' +
|
|
'Status: opgeslagen in competitie';
|
|
if (pid === verifierId) {
|
|
msg = '✅ **Bedankt voor het verifiëren!**\n\n' + msg;
|
|
}
|
|
sendMsg(p.telegram_id, msg);
|
|
}
|
|
}
|
|
|
|
// Kort groepsbericht met uitslag (alleen als groep is ingesteld)
|
|
const groupChatId = process.env.GROUP_CHAT_ID ? Number(process.env.GROUP_CHAT_ID) : null;
|
|
if (groupChatId) {
|
|
const setOneLine = Array.isArray(updated.score.sets)
|
|
? updated.score.sets.map((s, i) => s.team1 + '-' + s.team2).join(' ')
|
|
: updated.score.score;
|
|
sendMsg(groupChatId,
|
|
'🏓 **Uitslag**\n' +
|
|
t1Names + ' vs ' + t2Names + '\n' +
|
|
setOneLine + '\n' +
|
|
'✅ Score opgeslagen via @padel_nl_bot'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Verwijder het oorspronkelijke verificatie bericht
|
|
try {
|
|
bot.editMessageText('✅ Score geverifieerd!', {
|
|
chat_id: chatId,
|
|
message_id: query.message.message_id
|
|
}).catch(() => {});
|
|
} catch(e) {
|
|
logError('score_verify editMessageText:', e.message);
|
|
}
|
|
} catch(e) {
|
|
logError('score_verify callback:', e.message);
|
|
sendMsg(chatId, 'Fout bij verifiëren: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Score — wijs af / corrigeer
|
|
if (data.startsWith('score_reject_')) {
|
|
const parts = data.split('_');
|
|
const matchId = parseInt(parts[2]);
|
|
const rejecterId = parseInt(parts[3]);
|
|
|
|
// Security check: alleen de juiste verifier kan afwijzen
|
|
const players = await api('/players');
|
|
const clickingPlayer = Array.isArray(players) ? players.find(p => p.telegram_id == chatId) : null;
|
|
if (!clickingPlayer || Number(clickingPlayer.id) !== rejecterId) {
|
|
bot.answerCallbackQuery(query.id, { text: 'Je bent niet de verifier ❌' }).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
bot.answerCallbackQuery(query.id, { text: 'Voer de juiste score in' }).catch(() => {});
|
|
|
|
setUserState(chatId, {
|
|
step: 'awaiting_score_correction',
|
|
matchId: matchId,
|
|
player: clickingPlayer
|
|
});
|
|
|
|
sendMsg(chatId, 'De score klopt niet. Wat is de juiste score?\n\nTyp de setstanden, bijvoorbeeld:\n`6-4 3-6 10-5`');
|
|
}
|
|
|
|
// Match accept/reject
|
|
if (data.startsWith('match_accept_') || data.startsWith('match_reject_')) {
|
|
const parts = data.split('_');
|
|
const action = parts[1];
|
|
const matchId = parseInt(parts[2]);
|
|
|
|
try {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) throw new Error('Invalid players response');
|
|
const player = players.find(p => p.telegram_id == chatId);
|
|
if (!player) return;
|
|
|
|
const response = action === 'accept' ? 'accepted' : 'rejected';
|
|
const updated = await api('/matches/' + matchId + '/respond', 'POST', {
|
|
player_id: player.id,
|
|
response: response
|
|
});
|
|
|
|
bot.answerCallbackQuery(query.id, { text: response === 'accepted' ? 'Aanwezig!' : 'Afgewezen' }).catch(() => {});
|
|
|
|
// Bei match bevestigd: stuur DM naar alle spelers met boeker info
|
|
if (updated && updated.status === 'confirmed') {
|
|
const bookerName = updated.booker_name || 'Iemand';
|
|
const allNames = await getPlayerNames(updated.players || []);
|
|
for (const pid of (updated.players || [])) {
|
|
const p = players.find(pl => pl.id === pid);
|
|
if (p && p.telegram_id) {
|
|
var msg = '';
|
|
if (pid === updated.booker_id) {
|
|
msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\nJIJ moet de baan boeken! Regel het op tijd.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.';
|
|
} else {
|
|
msg = '🎾 **Match bevestigd!** ' + updated.date + ' ' + updated.start + '\n' + allNames.join(', ') + '\n\n' + bookerName + ' regelt de baanreservering.\n\n📝 **Na de wedstrijd:** stuur /score om de uitslag in te vullen.';
|
|
}
|
|
sendMsg(p.telegram_id, msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bij afwijzing: stuur DM naar andere spelers
|
|
if (response === 'rejected' && updated && updated.status === 'cancelled') {
|
|
const rejectName = player.name;
|
|
for (const pid of (updated.players || [])) {
|
|
if (pid === player.id) continue;
|
|
const p = players.find(pl => pl.id === pid);
|
|
if (p && p.telegram_id) {
|
|
sendMsg(p.telegram_id,
|
|
rejectName + ' heeft afgezegd voor ' + updated.date + ' ' + updated.start + '.\n\nNiet genoeg spelers meer. Stuur /match voor een nieuwe poging.'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Plan score reminder na eindtijd + 5 min (alleen ranked)
|
|
if (m.match_type === 'ranked' && updated.date && updated.end) {
|
|
const now = new Date();
|
|
const [endH, endM] = updated.end.split(':').map(Number);
|
|
const matchEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endH, endM);
|
|
const delay = Math.max(0, matchEnd.getTime() - now.getTime() + 5 * 60 * 1000);
|
|
setTimeout(() => {
|
|
const playerIds = updated.players || [];
|
|
for (const pid of playerIds) {
|
|
const p = players.find(pl => pl.id === pid);
|
|
if (p && p.telegram_id) {
|
|
sendMsg(p.telegram_id, '📝 **Wedstrijd is afgelopen!**\n\nStuur /score om de uitslag in te vullen.\n\nVoorbeeld: /score 6-4 7-5', { parse_mode: 'Markdown' }).catch(() => {});
|
|
}
|
|
}
|
|
}, delay);
|
|
log('Score reminder gepland voor match ' + matchId + ' over ' + Math.round(delay/60000) + ' min');
|
|
}
|
|
}
|
|
|
|
bot.editMessageText('Je hebt gereageerd: ' + response, {
|
|
chat_id: chatId,
|
|
message_id: query.message.message_id
|
|
}).catch(err => logError('editMessageText error:', err.message));
|
|
} catch (e) {
|
|
logError('match response error:', e.message);
|
|
bot.answerCallbackQuery(query.id, { text: 'Fout' }).catch(() => {});
|
|
}
|
|
// ─── Match accept: ranked/friendly stem ───
|
|
if (data.startsWith('match_accept_') || data.startsWith('match_reject_')) {
|
|
const parts = data.split('_');
|
|
const action = parts[1];
|
|
const matchId = parts[2];
|
|
|
|
try {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) throw new Error('Invalid players response');
|
|
const player = players.find(p => p.telegram_id == chatId);
|
|
if (!player) return;
|
|
|
|
if (action === 'reject') {
|
|
const updated = await api('/matches/' + matchId + '/respond', 'POST', {
|
|
player_id: player.id,
|
|
response: 'rejected'
|
|
});
|
|
bot.answerCallbackQuery(query.id, { text: 'Afgewezen' }).catch(() => {});
|
|
|
|
if (updated && updated.status === 'cancelled') {
|
|
for (const pid of (updated.players || [])) {
|
|
if (pid === player.id) continue;
|
|
const p = players.find(pl => pl.id === pid);
|
|
if (p && p.telegram_id) {
|
|
sendMsg(p.telegram_id, player.name + ' heeft afgezegd voor ' + updated.date + ' ' + updated.start + '.\\n\\nNiet genoeg spelers meer. Stuur /match voor een nieuwe poging.');
|
|
}
|
|
}
|
|
}
|
|
bot.editMessageText('Je hebt afgezegd.', { chat_id: chatId, message_id: query.message.message_id }).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
const matchInfo = await api('/matches/' + matchId);
|
|
if (!matchInfo) throw new Error('Match not found');
|
|
|
|
const getName = id => { const p = players.find(x => x.id == id); return p ? p.name : '?'; };
|
|
const t1names = (matchInfo.proposed_teams?.team1 || []).map(getName).join(' + ');
|
|
const t2names = (matchInfo.proposed_teams?.team2 || []).map(getName).join(' + ');
|
|
|
|
acceptingMatch[matchId] = {
|
|
players: matchInfo.players || [],
|
|
date: matchInfo.date,
|
|
start: matchInfo.start,
|
|
location: matchInfo.location,
|
|
proposed_teams: matchInfo.proposed_teams,
|
|
ranked: new Set(),
|
|
friendly: new Set()
|
|
};
|
|
|
|
bot.answerCallbackQuery(query.id, { text: 'Aanwezig!' }).catch(() => {});
|
|
|
|
sendMsg(chatId,
|
|
'🎾 **Je bevestigt deelname!**\\n\\n' + matchInfo.date + ' ' + matchInfo.start + '\\n🟡 ' + t1names + '\\n🔵 ' + t2names + '\\n\\n**Is dit een ranked of friendly wedstrijd?**',
|
|
{
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '🏆 Ranked', callback_data: 'vote_ranked_' + matchId },
|
|
{ text: '🟢 Friendly', callback_data: 'vote_friendly_' + matchId }]
|
|
]
|
|
}
|
|
}
|
|
);
|
|
|
|
bot.editMessageText('Je hebt bevestigd — kies ranked of friendly in DM.', {
|
|
chat_id: chatId,
|
|
message_id: query.message.message_id
|
|
}).catch(() => {});
|
|
|
|
} catch (e) {
|
|
logError('match response error:', e.message);
|
|
bot.answerCallbackQuery(query.id, { text: 'Fout' }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ─── Vote: ranked/friendly keuze ───
|
|
if (data.startsWith('vote_ranked_') || data.startsWith('vote_friendly_')) {
|
|
const matchId = data.split('_')[2];
|
|
const vote = data.startsWith('vote_ranked_') ? 'ranked' : 'friendly';
|
|
|
|
const m = acceptingMatch[matchId];
|
|
if (!m) {
|
|
bot.answerCallbackQuery(query.id, { text: 'Match niet gevonden of verlopen' }).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
const players = await api('/players');
|
|
const player = players.find(p => p.telegram_id == chatId);
|
|
if (!player) return;
|
|
|
|
if (m.ranked.has(player.id) || m.friendly.has(player.id)) {
|
|
bot.answerCallbackQuery(query.id, { text: 'Je hebt al gestemd!' }).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
if (vote === 'ranked') m.ranked.add(player.id);
|
|
else m.friendly.add(player.id);
|
|
|
|
const rankedCount = m.ranked.size;
|
|
const friendlyCount = m.friendly.size;
|
|
const totalResponses = rankedCount + friendlyCount;
|
|
const getName = id => { const p = players.find(x => x.id == id); return p ? p.name : '?'; };
|
|
const t1names = (m.proposed_teams?.team1 || []).map(getName).join(' + ');
|
|
const t2names = (m.proposed_teams?.team2 || []).map(getName).join(' + ');
|
|
|
|
bot.answerCallbackQuery(query.id, { text: 'Je stem is geregistraerd!' }).catch(() => {});
|
|
|
|
if (rankedCount >= 3) {
|
|
await finalizeMatch(matchId, 'ranked');
|
|
} else if (friendlyCount >= 3) {
|
|
await finalizeMatch(matchId, 'friendly');
|
|
} else if (totalResponses === 4) {
|
|
await finalizeMatch(matchId, 'friendly');
|
|
} else {
|
|
const stemmen = 'Stemmen: ' + rankedCount + ' ranked, ' + friendlyCount + ' friendly';
|
|
bot.editMessageText(
|
|
'🎾 **Je bevestigt deelname!**\\n\\n' + m.date + ' ' + m.start + '\\n🟡 ' + t1names + '\\n🔵 ' + t2names + '\\n\\n' + stemmen + '\\n\\n**Is dit een ranked of friendly wedstrijd?**',
|
|
{
|
|
chat_id: chatId,
|
|
message_id: query.message.message_id,
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '🏆 Ranked', callback_data: 'vote_ranked_' + matchId },
|
|
{ text: '🟢 Friendly', callback_data: 'vote_friendly_' + matchId }]
|
|
]
|
|
}
|
|
}
|
|
).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ─── Finaliseer match ───
|
|
async function finalizeMatch(matchId, finalType) {
|
|
const players = await api('/players');
|
|
const m = acceptingMatch[matchId];
|
|
if (!m) return;
|
|
|
|
try { await api('/matches/' + matchId + '/type', 'POST', { match_type: finalType }); } catch(e) {}
|
|
|
|
const getName = id => { const p = players.find(x => x.id == id); return p ? p.name : '?'; };
|
|
const t1names = (m.proposed_teams?.team1 || []).map(getName).join(' + ');
|
|
const t2names = (m.proposed_teams?.team2 || []).map(getName).join(' + ');
|
|
const typeLabel = finalType === 'ranked' ? '🏆 **Ranked** — score telt mee!' : '🟢 **Friendly**';
|
|
|
|
for (const pid of m.players) {
|
|
const p = players.find(x => x.id == pid);
|
|
if (p && p.telegram_id) {
|
|
sendMsg(p.telegram_id,
|
|
'🎾 **Match bevestigd!**\\n\\n' + m.date + ' ' + m.start + '\\n🟡 ' + t1names + '\\n🔵 ' + t2names + '\\n\\n' + typeLabel + '\\n\\nBoek de baan en vergeet niet /score in te vullen na de wedstrijd!',
|
|
{ parse_mode: 'Markdown' }
|
|
);
|
|
}
|
|
}
|
|
|
|
delete acceptingMatch[matchId];
|
|
log('Match ' + matchId + ' finalized as ' + finalType);
|
|
}
|
|
} else if (data.startsWith('team_swap_')) {
|
|
const matchId = data.split('_')[2];
|
|
try {
|
|
const teams = await api('/matches/' + matchId + '/teams');
|
|
if (!teams || !teams.teams) {
|
|
return bot.answerCallbackQuery(query.id, { text: 'Geen teams om te wisselen' }).catch(() => {});
|
|
}
|
|
// Wissel team1 en team2
|
|
const { team1, team2 } = teams.teams;
|
|
await api('/matches/' + matchId + '/teams', 'POST', {
|
|
team1: team2,
|
|
team2: team1
|
|
});
|
|
|
|
// Haal spelernamen op
|
|
const players = await api('/players');
|
|
const getName = id => { const p = players.find(x => x.id === id); return p ? p.name : '?'; };
|
|
const t1 = team2.map(getName).join(' + ');
|
|
const t2 = team1.map(getName).join(' + ');
|
|
|
|
// Stuur DM naar alle spelers
|
|
const matchInfo = await api('/matches/' + matchId);
|
|
if (matchInfo && Array.isArray(matchInfo.players)) {
|
|
for (const pid of matchInfo.players) {
|
|
const p = players.find(x => x.id === pid);
|
|
if (p && p.telegram_id) {
|
|
const typeLabelSwap = matchInfo.match_type === 'ranked' ? '🏆 Ranked' : '🟢 Friendly';
|
|
sendMsg(p.telegram_id,
|
|
'🔄 **Teams gewisseld!**\n\n' + typeLabelSwap + '\n\nNieuwe indeling voor ' + matchInfo.date + ' ' + matchInfo.start + ':\n🟡 ' + t1 + '\n🔵 ' + t2,
|
|
{
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + matchId },
|
|
{ text: '❌ Nee', callback_data: 'match_reject_' + matchId }]
|
|
]
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update het originele bericht
|
|
bot.editMessageText(
|
|
'🎾 **Match — teams gewisseld!**\n\n📅 ' + (matchInfo ? matchInfo.date + ' ' + matchInfo.start : '') + '\n\n🏆 **Teams:**\n🟡 ' + t1 + '\n🔵 ' + t2 + '\n\nReageer met ✅ of ❌',
|
|
{
|
|
chat_id: query.message.chat.id,
|
|
message_id: query.message.message_id,
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + matchId },
|
|
{ text: '❌ Nee', callback_data: 'match_reject_' + matchId }],
|
|
[{ text: '🔄 Weer wisselen', callback_data: 'team_swap_' + matchId }]
|
|
]
|
|
}
|
|
}
|
|
).catch(e => logError('team_swap edit fail:', e.message));
|
|
|
|
bot.answerCallbackQuery(query.id, { text: 'Teams gewisseld!' }).catch(() => {});
|
|
} catch (e) {
|
|
logError('team_swap error:', e.message);
|
|
bot.answerCallbackQuery(query.id, { text: 'Fout bij wisselen' }).catch(() => {});
|
|
}
|
|
|
|
}
|
|
});
|
|
|
|
// ─── Peakz baan check ───
|
|
const { spawn } = require('child_process');
|
|
|
|
// Valideer datum formaat: YYYY-MM-DD
|
|
function isValidDateStr(s) {
|
|
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
}
|
|
|
|
// Valideer tijd formaat: HH:MM
|
|
function isValidTimeStr(s) {
|
|
return /^\d{2}:\d{2}$/.test(s);
|
|
}
|
|
|
|
async function checkPeakzCourt(dateStr, timeStr, locationName) {
|
|
// dateStr = '2026-05-29', timeStr = '17:00', locationName = 'Atoomweg'|'Euroborg'|'Suikerterrein'
|
|
const loc = locationName || 'Atoomweg';
|
|
if (!isValidDateStr(dateStr) || !isValidTimeStr(timeStr)) {
|
|
logError('Peakz: ongeldige input', dateStr, timeStr);
|
|
return null;
|
|
}
|
|
try {
|
|
const result = await new Promise((resolve, reject) => {
|
|
const child = spawn('node', ['/app/padel-peakz.js', dateStr, loc, '--json'], {
|
|
timeout: 20000,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', d => stdout += d);
|
|
child.stderr.on('data', d => stderr += d);
|
|
child.on('close', code => {
|
|
if (code !== 0) return reject(new Error('Exit code ' + code + ': ' + stderr.slice(0, 200)));
|
|
resolve(stdout);
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
const data = JSON.parse(result);
|
|
if (!data.available || !Array.isArray(data.available)) return false;
|
|
return data.available.some(s => s.time === timeStr);
|
|
} catch (e) {
|
|
logError('Peakz check error:', e.message);
|
|
return null; // null = onbekend (error)
|
|
}
|
|
}
|
|
|
|
// ─── Matching algorithm ───
|
|
async function runMatching() {
|
|
const players = await api('/players');
|
|
if (!Array.isArray(players)) throw new Error('Invalid players');
|
|
const matches = await api('/matches');
|
|
|
|
const today = new Date();
|
|
const dayNames = ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag'];
|
|
|
|
const daySlots = {};
|
|
for (let d = 0; d < 7; d++) {
|
|
const date = new Date(today);
|
|
date.setDate(today.getDate() + d);
|
|
const dayName = dayNames[date.getDay()];
|
|
if (!daySlots[dayName]) daySlots[dayName] = {};
|
|
|
|
for (const p of players) {
|
|
const avail = p.availability || {};
|
|
let slots = avail[dayName] || [];
|
|
|
|
// Merge vaste beschikbaarheid met tijdelijke (availability_temp)
|
|
const temp = p.availability_temp || null;
|
|
if (temp) {
|
|
// Bereken ISO week voor deze datum
|
|
const d2 = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
const dayNum2 = d2.getUTCDay() || 7;
|
|
d2.setUTCDate(d2.getUTCDate() + 4 - dayNum2);
|
|
const yearStart = new Date(Date.UTC(d2.getUTCFullYear(), 0, 1));
|
|
const weekNum = Math.ceil((((d2 - yearStart) / 86400000) + 1) / 7);
|
|
const weekKey = date.getFullYear() + '-W' + String(weekNum).padStart(2, '0');
|
|
|
|
if (temp[weekKey] && temp[weekKey][dayName]) {
|
|
slots = [...slots, ...temp[weekKey][dayName]];
|
|
// Remove duplicates (same start time)
|
|
slots = slots.filter((s, i, arr) => arr.findIndex(x => x.start === s.start) === i);
|
|
}
|
|
}
|
|
|
|
if (slots.length === 0) continue;
|
|
|
|
// Gebruik de date uit de loop (vandaag + d dagen)
|
|
const matchDateStr = date.toISOString().split('T')[0];
|
|
const rejectedSlots = p.rejected_slots || {};
|
|
|
|
const toMin = s => { const [h, m] = s.split(':').map(Number); return h * 60 + m; };
|
|
|
|
const availSlots = (avail[dayName] || []).sort((a, b) => toMin(a.start) - toMin(b.start));
|
|
if (availSlots.length === 0) continue;
|
|
|
|
let blockStart = toMin(availSlots[0].start);
|
|
let blockEnd = toMin(availSlots[0].end);
|
|
for (let si = 1; si < availSlots.length; si++) {
|
|
const s = availSlots[si];
|
|
if (toMin(s.start) === blockEnd) {
|
|
blockEnd = toMin(s.end);
|
|
} else {
|
|
blockEnd = Math.max(blockEnd, toMin(s.end));
|
|
}
|
|
}
|
|
|
|
const availStart = toMin(availSlots[0].start);
|
|
const availEnd = toMin(availSlots[availSlots.length - 1].end);
|
|
|
|
for (let t = availStart; t + 90 <= availEnd; t += 30) {
|
|
const blockStart = String(Math.floor(t / 60)).padStart(2, '0') + ':' + String(t % 60).padStart(2, '0');
|
|
const blockEnd = String(Math.floor((t + 90) / 60)).padStart(2, '0') + ':' + String((t + 90) % 60).padStart(2, '0');
|
|
const rejectKey = matchDateStr + '_' + blockStart;
|
|
if (rejectedSlots[rejectKey]) continue;
|
|
const key = dayName + '_' + blockStart + '_' + blockEnd;
|
|
if (!daySlots[dayName][key]) daySlots[dayName][key] = { offset: d, players: new Set() };
|
|
daySlots[dayName][key].players.add(p);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [day, slots] of Object.entries(daySlots)) {
|
|
// Sorteer slots: die met meeste spelers eerst
|
|
const sortedSlots = Object.entries(slots)
|
|
.map(([key, s]) => ({ key, players: [...s.players], offset: s.offset }))
|
|
.filter(s => s.players.length >= 4)
|
|
.sort((a, b) => b.players.length - a.players.length);
|
|
|
|
for (const { key, players: slotPlayers } of sortedSlots) {
|
|
const parts = key.split('_');
|
|
const start = parts[1], end = parts[2];
|
|
|
|
const dayOffset = slotPlayers.offset ?? d;
|
|
// Bepaal de ECHTE datum (vandaag + offset dagen)
|
|
const matchDate = new Date(today);
|
|
matchDate.setDate(today.getDate() + dayOffset);
|
|
const matchDateStr = matchDate.toISOString().split('T')[0];
|
|
|
|
// Check of er al een match is op deze dag+tijd
|
|
const alreadyMatched = (matches || []).some(m =>
|
|
(m.date === matchDateStr || m.day === day) && m.start === start
|
|
);
|
|
if (alreadyMatched) continue;
|
|
|
|
// Peakz check: probeer Atoomweg → Euroborg → Suikerterrein
|
|
const peakzLocations = ['Atoomweg', 'Euroborg', 'Suikerterrein'];
|
|
let peakzFree = false;
|
|
let peakzLocation = null;
|
|
for (const loc of peakzLocations) {
|
|
const result = await checkPeakzCourt(matchDateStr, start, loc);
|
|
if (result === true) {
|
|
peakzFree = true;
|
|
peakzLocation = loc;
|
|
break;
|
|
}
|
|
if (result === null) {
|
|
// null = error — onthoud maar ga door naar volgende locatie
|
|
log('Peakz: check mislukt voor', loc, matchDateStr, start);
|
|
}
|
|
}
|
|
|
|
if (!peakzFree) {
|
|
log('Peakz: geen baan beschikbaar op', matchDateStr, start, '— skip');
|
|
continue;
|
|
}
|
|
|
|
// Weer-check voor Suikerterrein (buitenbaan)
|
|
if (peakzLocation === 'Suikerterrein') {
|
|
try {
|
|
const http = require('http');
|
|
const weatherData = await new Promise((resolve, reject) => {
|
|
http.get('http://api.open-meteo.com/v1/forecast?latitude=53.2194&longitude=6.5667&hourly=precipitation_probability&timezone=Europe/Amsterdam&forecast_days=7', (res) => {
|
|
let body = '';
|
|
res.on('data', c => body += c);
|
|
res.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
|
|
}).on('error', reject);
|
|
});
|
|
if (weatherData && weatherData.hourly) {
|
|
const hour = parseInt(start.split(':')[0]);
|
|
const times = weatherData.hourly.time || [];
|
|
const precip = weatherData.hourly.precipitation_probability || [];
|
|
const idx = times.findIndex(t => new Date(t).getHours() === hour);
|
|
if (idx >= 0 && precip[idx] > 50) {
|
|
log('Peakz: Suikerterrein heeft regenkans', precip[idx], '% op', matchDateStr, start, '— skip');
|
|
continue;
|
|
}
|
|
}
|
|
} catch(e) {
|
|
log('Peakz: weer-check mislukt voor Suikerterrein — ga door');
|
|
}
|
|
}
|
|
|
|
// Level-based optimalisatie: kies best passende 4 spelers
|
|
// Prioriteit: dichtstbijzijnde levels, bij gelijke keuze balanced teams
|
|
let best4 = slotPlayers.slice(0, 4);
|
|
|
|
if (slotPlayers.length > 4) {
|
|
// Sorteer op level, zoek cluster van 4 met kleinste level-range
|
|
const sorted = [...slotPlayers].sort((a, b) => (a.level || 5) - (b.level || 5));
|
|
let bestSpread = Infinity;
|
|
for (let i = 0; i <= sorted.length - 4; i++) {
|
|
const group = sorted.slice(i, i + 4);
|
|
const spread = (group[3].level || 5) - (group[0].level || 5);
|
|
if (spread < bestSpread) {
|
|
bestSpread = spread;
|
|
best4 = group;
|
|
}
|
|
}
|
|
|
|
// Als er meerdere groepen met dezelfde spread zijn, kies die met
|
|
// de meest gebalanceerde teams (team1 gem. level ≈ team2 gem. level)
|
|
if (slotPlayers.length >= 6) {
|
|
let bestBalance = Infinity;
|
|
let balanced4 = best4;
|
|
for (let i = 0; i <= sorted.length - 4; i++) {
|
|
const group = sorted.slice(i, i + 4);
|
|
const spread = (group[3].level || 5) - (group[0].level || 5);
|
|
if (spread > bestSpread + 1) continue; // alleen bijzelfde spread
|
|
// Balanceer teams: hoog+laag vs mid
|
|
const t1 = [group[0], group[3]];
|
|
const t2 = [group[1], group[2]];
|
|
const avg1 = (t1[0].level || 5) + (t1[1].level || 5);
|
|
const avg2 = (t2[0].level || 5) + (t2[1].level || 5);
|
|
const balance = Math.abs(avg1 - avg2);
|
|
if (balance < bestBalance) {
|
|
bestBalance = balance;
|
|
balanced4 = group;
|
|
}
|
|
}
|
|
best4 = balanced4;
|
|
}
|
|
}
|
|
|
|
// Balanceer 4 spelers in 2 teams: hoog + laag vs mid
|
|
const sorted = [...best4].sort((a, b) => (a.level || 5) - (b.level || 5));
|
|
const proposedTeam1 = [sorted[0], sorted[3]]; // hoogste + laagste
|
|
const proposedTeam2 = [sorted[1], sorted[2]]; // midden twee
|
|
|
|
const match = await api('/matches', 'POST', {
|
|
day, start, end, date: matchDateStr,
|
|
players: best4.map(p => p.id),
|
|
telegram_ids: best4.filter(p => p.telegram_id).map(p => p.telegram_id),
|
|
match_type: 'friendly'
|
|
});
|
|
|
|
// Sla voorgestelde teams op
|
|
await api('/matches/' + match.id + '/teams', 'POST', {
|
|
team1: proposedTeam1.map(p => p.id),
|
|
team2: proposedTeam2.map(p => p.id)
|
|
});
|
|
|
|
const t1names = proposedTeam1.map(p => p.name).join(' + ');
|
|
const t2names = proposedTeam2.map(p => p.name).join(' + ');
|
|
|
|
for (const p of best4) {
|
|
if (p.telegram_id) {
|
|
sendMsg(p.telegram_id,
|
|
'🎾 **Match voorgesteld!**\n\n📅 ' + day + ' ' + matchDateStr + '\n⏰ ' + start + '-' + end + '\n\n🏆 **Teams:**\n🟡 ' + t1names + '\n🔵 ' + t2names + '\n\n📍 ' + peakzLocation + '\n\nType: 🟢 Friendly — 1 speler kan deze ranked maken',
|
|
{
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + match.id },
|
|
{ text: '❌ Nee', callback_data: 'match_reject_' + match.id }],
|
|
[{ text: '🔄 Andere teams', callback_data: 'team_swap_' + match.id }]
|
|
]
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Ook naar groeps-chat met team indeling
|
|
if (targetChat) {
|
|
sendMsg(targetChat,
|
|
'🎾 **Match gevonden!**\n\n📅 ' + day + ' ' + matchDateStr + '\n⏰ ' + start + '-' + end + '\n\n🏆 **Teams:**\n🟡 ' + t1names + '\n🔵 ' + t2names + '\n\n📍 ' + peakzLocation + '\n\nType: 🟢 Friendly — 1 speler kan deze ranked maken',
|
|
{
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + match.id },
|
|
{ text: '❌ Nee', callback_data: 'match_reject_' + match.id }],
|
|
[{ text: '🔄 Andere teams', callback_data: 'team_swap_' + match.id }]
|
|
]
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
return { day, start, end, players: best4, match, proposedTeam1, proposedTeam2 };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── New chat members welcome ───
|
|
bot.on('new_chat_members', async (msg) => {
|
|
const chatId = msg.chat.id;
|
|
const botId = parseInt(TOKEN.split(':')[0]);
|
|
const isBot = msg.new_chat_members.some(m => m.id === botId || m.is_bot);
|
|
|
|
if (isBot) {
|
|
sendMsg(chatId, '🏓 **Welkom bij Padel Matcher!**\n\nIk help jullie met het vinden van matches en het bijhouden van scores.\n\n**Wat nu?**\n1️⃣ Stuur /setgroup om deze groep in te stellen\n2️⃣ Iedereen registreert via /start in DM met mij\n3️⃣ Stel beschikbaarheid in via: https://padel.iamdoingthings.com\n4️⃣ Stuur /match om te checken of er 4 spelers beschikbaar zijn\n\nCommands:\n/wie — wie is er geregistreerd\n/match — zoek een match\n/setgroup — zet notificaties aan voor deze groep',
|
|
{ parse_mode: 'Markdown', disable_web_page_preview: true }
|
|
);
|
|
} else {
|
|
const names = msg.new_chat_members.map(m => m.first_name || m.username || 'Speler').join(', ');
|
|
sendMsg(chatId, '🎾 Welkom ' + names + '!\n\nRegistreer via /start in DM met @padel_nl_bot en ontvang een PIN.\nDaarna kun je beschikbaarheid opgeven op: https://padel.iamdoingthings.com',
|
|
{ disable_web_page_preview: true }
|
|
);
|
|
}
|
|
});
|
|
|
|
// ─── Bot command menu instellen ───
|
|
bot.setMyCommands([
|
|
{ command: 'nu', description: 'Ik kan nu spelen (meteen opslaan)' },
|
|
{ command: 'beschikbaar', description: 'Beschikbaarheid beheren' },
|
|
{ command: 'mijn', description: 'Jouw stats en PIN (alleen DM)' },
|
|
{ command: 'start', description: 'Registreren / PIN ophalen' },
|
|
{ command: 'score', description: 'Uitslag invoeren (alleen DM)' },
|
|
{ command: 'matches', description: 'Openstaande voorstellen bekijken' },
|
|
{ command: 'banen', description: 'Vrije banen bij Peakz checken' },
|
|
{ command: 'wie', description: 'Overzicht geregistreerde spelers' },
|
|
{ command: 'match', description: 'Nieuwe match zoeken' },
|
|
{ command: 'feedback', description: 'Stuur feedback naar beheerder' },
|
|
{ command: 'ping', description: 'Check of bot online is' },
|
|
]).catch(e => logError('Set commands error:', e.message));
|
|
|
|
// ─── Polling error handler ───
|
|
bot.on('polling_error', (err) => {
|
|
logError('Polling error:', err.message);
|
|
});
|
|
|
|
log('🤖 Padel Bot started, polling...');
|
|
|
|
// ─── Auto-match HTTP endpoint (for cron) ───
|
|
const httpServer = http.createServer(async (req, res) => {
|
|
if (req.url === '/match-check' && req.method === 'POST') {
|
|
try {
|
|
const result = await runMatching();
|
|
if (result) {
|
|
const target = process.env.GROUP_CHAT_ID;
|
|
if (target) {
|
|
const names = result.players.map(p => p.name).join(', ');
|
|
sendMsg(Number(target),
|
|
'🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌',
|
|
{
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id },
|
|
{ text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }]
|
|
]
|
|
}
|
|
}
|
|
);
|
|
}
|
|
for (const p of result.players) {
|
|
if (p.telegram_id) {
|
|
sendMsg(p.telegram_id,
|
|
'🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches',
|
|
{ parse_mode: 'Markdown' }
|
|
);
|
|
}
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, match: result.match.id }));
|
|
} else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, match: null }));
|
|
}
|
|
} catch (e) {
|
|
logError('Match-check HTTP error:', e.message);
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
}
|
|
});
|
|
|
|
httpServer.listen(PORT, () => {
|
|
log('Match-check HTTP server on port ' + PORT);
|
|
});
|
|
|
|
// ─── Auto-match elke 30 min ───
|
|
setInterval(async () => {
|
|
try {
|
|
const result = await runMatching();
|
|
if (result) {
|
|
log('Auto-match found:', result.day, result.start);
|
|
const target = process.env.GROUP_CHAT_ID;
|
|
if (target) {
|
|
const names = result.players.map(p => p.name).join(', ');
|
|
sendMsg(Number(target),
|
|
'🎾 **Match gevonden!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + names + '\n\nReageer met ✅ of ❌',
|
|
{
|
|
parse_mode: 'Markdown',
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[{ text: '✅ Ja, ik kan!', callback_data: 'match_accept_' + result.match.id },
|
|
{ text: '❌ Nee', callback_data: 'match_reject_' + result.match.id }]
|
|
]
|
|
}
|
|
}
|
|
);
|
|
}
|
|
for (const p of result.players) {
|
|
if (p.telegram_id) {
|
|
sendMsg(p.telegram_id,
|
|
'🎾 **Match voorgesteld!**\n\n📅 ' + result.day + '\n⏰ ' + result.start + '-' + result.end + '\n👥 ' + result.players.map(x => x.name).join(', ') + '\n\nReageer: /matches',
|
|
{ parse_mode: 'Markdown' }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logError('Auto-match error:', e.message);
|
|
}
|
|
}, 30 * 60 * 1000);
|
|
|
|
// ─── /banen — Peakz beschikbaarheid checken ───
|
|
bot.onText(/\/banen(@padel_nl_bot)?(\s+(.+))?/, async (msg, match) => {
|
|
const chatId = msg.chat.id;
|
|
const rest = match ? match[3] : null;
|
|
|
|
let targetDate = new Date();
|
|
let targetLocation = 'Atoomweg';
|
|
|
|
const LOCATION_MAP = {
|
|
atoomweg: 'Atoomweg', atoom: 'Atoomweg',
|
|
euroborg: 'Euroborg', euro: 'Euroborg',
|
|
suikerterrein: 'Suikerterrein', suiker: 'Suikerterrein', buiten: 'Suikerterrein'
|
|
};
|
|
|
|
if (rest && rest.trim()) {
|
|
const parts = rest.trim().split(/\s+/);
|
|
const dagMap = { ma:1, di:2, wo:3, do:4, vr:5, za:6, zo:0 };
|
|
const dagNamen = ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag'];
|
|
let dateParsed = false;
|
|
const eerste = parts[0].toLowerCase();
|
|
|
|
if (dagMap[eerste] !== undefined) {
|
|
let dagen = (dagMap[eerste] - new Date().getDay() + 7) % 7;
|
|
if (dagen === 0) dagen = 7;
|
|
targetDate.setDate(targetDate.getDate() + dagen);
|
|
dateParsed = true;
|
|
} else if (parts[0].match(/^\d{4}-\d{2}-\d{2}$/)) {
|
|
targetDate = new Date(parts[0] + 'T12:00:00');
|
|
dateParsed = true;
|
|
} else if (dagNamen.indexOf(eerste) >= 0) {
|
|
let dagen = (dagNamen.indexOf(eerste) - new Date().getDay() + 7) % 7;
|
|
if (dagen === 0) dagen = 7;
|
|
targetDate.setDate(targetDate.getDate() + dagen);
|
|
dateParsed = true;
|
|
}
|
|
|
|
const locPart = dateParsed ? (parts[1] || '').toLowerCase() : parts.join(' ').toLowerCase();
|
|
for (const [alias, loc] of Object.entries(LOCATION_MAP)) {
|
|
if (locPart === alias || locPart.includes(alias)) {
|
|
targetLocation = loc;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const dateStr = targetDate.toISOString().split('T')[0];
|
|
|
|
await sendMsg(chatId, '🔍 Bezig met zoeken naar vrije banen bij Peakz ' + targetLocation + '...');
|
|
|
|
try {
|
|
if (!isValidDateStr(dateStr)) {
|
|
return sendMsg(chatId, '⚠️ Ongeldige datum. Gebruik /banen [dag] of /banen YYYY-MM-DD');
|
|
}
|
|
const result = await new Promise((resolve, reject) => {
|
|
const child = spawn('node', ['/app/padel-peakz.js', dateStr, targetLocation, '--json'], {
|
|
timeout: 20000, stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
let stdout = '', stderr = '';
|
|
child.stdout.on('data', d => stdout += d);
|
|
child.stderr.on('data', d => stderr += d);
|
|
child.on('close', code => {
|
|
if (code !== 0) return reject(new Error('Exit code ' + code + ': ' + stderr.slice(0, 200)));
|
|
resolve(stdout);
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
const data = JSON.parse(result);
|
|
if (data.error) return sendMsg(chatId, 'Fout: ' + data.error);
|
|
|
|
const dagKort = ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za'];
|
|
const d = new Date(data.date + 'T12:00:00');
|
|
const dagLabel = dagKort[d.getDay()] + ' ' + d.getDate() + '/' + (d.getMonth()+1);
|
|
const typeIcon = data.indoor ? '🏢' : '🌳';
|
|
const typeLabel = data.indoor ? 'Indoor' : 'Buiten';
|
|
const available = data.available || [];
|
|
|
|
let msg = typeIcon + ' **Peakz Padel — ' + data.location + '** (' + typeLabel + ')\n📅 ' + dagLabel + '\n\n';
|
|
|
|
if (available.length > 0) {
|
|
msg += '🟢 **Vrije banen** (' + available.length + '):\n';
|
|
const ochtend = available.filter(s => parseInt(s.time) < 12);
|
|
const middag = available.filter(s => parseInt(s.time) >= 12 && parseInt(s.time) < 17);
|
|
const avond = available.filter(s => parseInt(s.time) >= 17);
|
|
if (ochtend.length) msg += '🌅 Ochtend: ' + ochtend.map(s => s.time + (s.price ? ' (€' + s.price + ')' : '')).join(', ') + '\n';
|
|
if (middag.length) msg += '☀️ Middag: ' + middag.map(s => s.time + (s.price ? ' (€' + s.price + ')' : '')).join(', ') + '\n';
|
|
if (avond.length) msg += '🌙 Avond: ' + avond.map(s => s.time + (s.price ? ' (€' + s.price + ')' : '')).join(', ') + '\n';
|
|
|
|
// Weer-check voor buitenbanen
|
|
if (!data.indoor && available.length > 0) {
|
|
try {
|
|
const http = require('http');
|
|
const weatherData = await new Promise((resolve, reject) => {
|
|
http.get('http://api.open-meteo.com/v1/forecast?latitude=53.2194&longitude=6.5667&hourly=precipitation_probability,temperature_2m,weathercode&timezone=Europe/Amsterdam&forecast_days=7', (res) => {
|
|
let body = '';
|
|
res.on('data', c => body += c);
|
|
res.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
|
|
}).on('error', reject);
|
|
});
|
|
if (weatherData && weatherData.hourly) {
|
|
const times = weatherData.hourly.time || [];
|
|
const precip = weatherData.hourly.precipitation_probability || [];
|
|
const temps = weatherData.hourly.temperature_2m || [];
|
|
const weatherLines = available.slice(0, 8).map(s => {
|
|
const hour = parseInt(s.time.split(':')[0]);
|
|
const idx = times.findIndex(t => new Date(t).getHours() === hour);
|
|
if (idx >= 0) {
|
|
const p = precip[idx];
|
|
const t = temps[idx];
|
|
const icon = p > 50 ? '🌧️' : p > 20 ? '🌥️' : '☀️';
|
|
return ' ' + s.time + ' ' + icon + ' ' + Math.round(t) + '°C' + (p > 5 ? ' ' + p + '%' : '');
|
|
}
|
|
return ' ' + s.time + ' —';
|
|
}).join('\n');
|
|
msg += '\n🌤️ **Weer:**\n' + weatherLines + '\n';
|
|
}
|
|
} catch(e) { logError('Weather error:', e.message); }
|
|
}
|
|
} else {
|
|
msg += '🔴 Geen vrije banen deze dag\n';
|
|
}
|
|
|
|
msg += '\n💡 Stuur /banen [dag] [locatie]\n';
|
|
msg += ' Bijv: /banen za, /banen za euroborg, /banen za suiker';
|
|
|
|
await sendMsg(chatId, msg, { parse_mode: 'Markdown', disable_web_page_preview: true });
|
|
} catch (e) {
|
|
logError('/banen error:', e.message);
|
|
sendMsg(chatId, 'Fout bij ophalen: ' + e.message);
|
|
}
|
|
});
|
|
|