import dotenv from 'dotenv' import { pool } from './db.js' import { getUpdates, answerCallbackQuery, editMessageText, sendTelegramMessage } from './telegram.js' import { sendPostalEmail } from './postal.js' dotenv.config() function toFR(iso) { if (!iso) return '' const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso)) if (!m) return String(iso) return `${m[3]}/${m[2]}/${m[1]}` } function escapeHtml(s) { return String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>') } function parseLocalDateTime(dateStr, timeStr, end = false) { if (!dateStr) return null const [y, m, d] = String(dateStr).split('-').map(Number) let hh = 0, mm = 0 if (typeof timeStr === 'string' && timeStr.includes(':')) { const [h, mi] = timeStr.split(':').map(Number) hh = Number.isFinite(h) ? h : 0 mm = Number.isFinite(mi) ? mi : 0 } else if (end) { hh = 23; mm = 59 } if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null return new Date(y, m - 1, d, hh, mm, 0, 0) } function sanitizeBooking(row) { const normDate = (v) => { if (!v) return v if (typeof v === 'string') { const exact = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v) if (exact) return `${exact[1]}-${exact[2]}-${exact[3]}` const d = new Date(v) if (!isNaN(d.getTime())) { const yyyy = d.getFullYear() const mm = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') return `${yyyy}-${mm}-${dd}` } return v } if (v instanceof Date) { const yyyy = v.getFullYear() const mm = String(v.getMonth() + 1).padStart(2, '0') const dd = String(v.getDate()).padStart(2, '0') return `${yyyy}-${mm}-${dd}` } return v } const normTime = (v) => { if (!v) return v if (typeof v === 'string') { const parts = v.split(':') if (parts.length >= 2) return `${parts[0].padStart(2,'0')}:${parts[1].padStart(2,'0')}` return v } return v } return { ...row, start_date: normDate(row.start_date), end_date: normDate(row.end_date), start_time: normTime(row.start_time), end_time: normTime(row.end_time), } } function getBikeTypes(row) { const types = [] if (Number.isFinite(row.bike_type)) types.push(Number(row.bike_type)) if (typeof row.bike_types === 'string' && row.bike_types.trim()) { for (const s of row.bike_types.split(',').map(x => x.trim()).filter(Boolean)) { const n = Number(s) if (Number.isFinite(n)) types.push(n) } } return Array.from(new Set(types)) } function overlaps(aStart, aEnd, bStart, bEnd) { return aStart < bEnd && bStart < aEnd } async function hasConflict(target) { const s = parseLocalDateTime(target.start_date, target.start_time) const e = parseLocalDateTime(target.end_date, target.end_time, true) const types = getBikeTypes(target) if (!s || !e || !types.length) return false const result = await pool.query( `SELECT * FROM bookings WHERE status = 'accepted' AND end_date >= $1 AND start_date <= $2`, [target.start_date, target.end_date] ) const candidates = result.rows.map(sanitizeBooking) for (const c of candidates) { const cs = parseLocalDateTime(c.start_date, c.start_time) const ce = parseLocalDateTime(c.end_date, c.end_time, true) if (!cs || !ce) continue if (!overlaps(s, e, cs, ce)) continue const cTypes = getBikeTypes(c) if (cTypes.some(t => types.includes(t))) return true } return false } function formatRangeLabel(b) { const sDate = b.start_date const eDate = b.end_date const st = (b.start_time && b.start_time.length) ? b.start_time : '00:00' const et = (b.end_time && b.end_time.length) ? b.end_time : '23:59' if (sDate && eDate && sDate === eDate) return `${toFR(sDate)} ${st} → ${et}` return `${toFR(sDate)} ${st} → ${toFR(eDate)} ${et}` } function formatDetails(b) { const bikeList = getBikeTypes(b).join(', ') || '—' const rangeLabel = formatRangeLabel(b) return `\nAssociation: ${escapeHtml(b.name || '—')}\nPériode: ${escapeHtml(rangeLabel)}\nCargobike(s): ${escapeHtml(bikeList)}` } async function acceptBooking(id) { const current = await pool.query('SELECT * FROM bookings WHERE id = $1', [id]) if (current.rowCount === 0) return { ok: false, reason: 'not_found' } const booking = sanitizeBooking(current.rows[0]) if (booking.status === 'accepted') return { ok: true, already: 'accepted', booking } if (booking.status === 'refused') return { ok: true, already: 'refused', booking } if (await hasConflict(booking)) { // Do NOT auto-refuse. Just report conflict with details. return { ok: false, reason: 'conflict', booking } } // Accept and send confirmation email const upd = await pool.query('UPDATE bookings SET status = $1 WHERE id = $2 RETURNING *', ['accepted', id]) const b = sanitizeBooking(upd.rows[0]) try { // Compose and send Postal email const bikeList = getBikeTypes(b).join(', ') const rangeLabel = formatRangeLabel(b) const recipients = String(b.email || '') .split(/[;,]/) .map(s => s.trim()) .filter(s => s.length > 0) if (recipients.length) { const subject = 'AGEP – Confirmation de réservation de cargobike' const greeting = b.name ? `Bonjour ${b.name},` : 'Bonjour,' const text = `${greeting}\n\nVotre réservation de cargobike a été validée.\n\nDétails de la réservation:\n- Période: ${rangeLabel}\n- Cargobike(s): ${bikeList || '—'}\n\nVous recevrez prochainement des informations complémentaires si nécessaire.\n\nCordialement,\nAssociation AGEP` const html = `\n

${escapeHtml(greeting)}

\n

Votre réservation de cargobike a été validée.

\n

Détails de la réservation
\n • Période: ${escapeHtml(rangeLabel)}
\n • Cargobike(s): ${escapeHtml(bikeList || '—')}\n

\n

Vous recevrez prochainement des informations complémentaires si nécessaire.

\n

Cordialement,
Association AGEP

\n ` await sendPostalEmail({ to: recipients, subject, text, html }) } } catch (e) { console.warn('Postal email send failed from bot:', e?.message || e) } return { ok: true, booking: b } } export async function startTelegramBot() { const token = process.env.TELEGRAM_BOT_TOKEN if (!token) throw new Error('TELEGRAM_BOT_TOKEN is not set') let offset = 0 console.log('Telegram bot polling started') const absFrontend = (() => { const base = String(process.env.FRONTEND_URL || '').trim() return /^https?:\/\//i.test(base) ? base.replace(/\/$/, '') : null })() async function handleCommand(message) { const chatId = message.chat?.id const text = String(message.text || '').trim() if (!chatId || !text) return // Support /res and /reservations (with optional bot mention) const cmd = text.split(/\s+/)[0] if (!/^\/(res(ervations)?)(@\w+)?$/i.test(cmd)) return // Fetch pending bookings (limit reasonable to avoid spam) const rs = await pool.query("SELECT * FROM bookings WHERE status = 'pending' ORDER BY created_at ASC LIMIT 20") const list = rs.rows.map(sanitizeBooking) if (list.length === 0) { await sendTelegramMessage({ text: '✅ Aucune demande en attente.', parseMode: 'HTML', chatIds: [String(chatId)] }) return } const adminNote = absFrontend ? `\nOuvrir l’admin: ${absFrontend}/admin` : '' await sendTelegramMessage({ text: `📋 ${list.length} demande(s) en attente. Vous pouvez accepter ci‑dessous.${adminNote}`, parseMode: 'HTML', chatIds: [String(chatId)] }) for (const b of list) { const bikeList = (() => { const types = getBikeTypes(b) return types.join(', ') })() const rangeLabel = (() => { const sDate = b.start_date const eDate = b.end_date const st = (b.start_time && b.start_time.length) ? b.start_time : '00:00' const et = (b.end_time && b.end_time.length) ? b.end_time : '23:59' if (sDate && eDate && sDate === eDate) return `${toFR(sDate)} ${st} → ${et}` return `${toFR(sDate)} ${st} → ${toFR(eDate)} ${et}` })() const msg = `🔔 Demande #${b.id}\nAssociation: ${escapeHtml(b.name)}\nPériode: ${escapeHtml(rangeLabel)}\nCargobike(s): ${escapeHtml(bikeList || '—')}` const buttons = [ { text: '✅ Accepter', callback_data: `accept:${b.id}` } ] if (absFrontend) buttons.push({ text: '📋 Admin', url: `${absFrontend}/admin` }) await sendTelegramMessage({ text: msg, parseMode: 'HTML', chatIds: [String(chatId)], replyMarkup: { inline_keyboard: [ buttons ] } }) } } ;(async function loop() { try { const updates = await getUpdates({ offset, timeout: 30 }) const list = Array.isArray(updates.result) ? updates.result : [] for (const u of list) { offset = u.update_id + 1 if (u.message && typeof u.message.text === 'string') { await handleCommand(u.message) continue } if (!u.callback_query) continue const cq = u.callback_query const data = cq.data || '' const chatId = cq.message?.chat?.id const messageId = cq.message?.message_id const cbId = cq.id if (!chatId || !messageId) { await answerCallbackQuery({ callbackQueryId: cbId, text: 'Message introuvable' }) continue } if (data.startsWith('accept:')) { const id = Number(data.split(':')[1]) if (!Number.isFinite(id)) { await answerCallbackQuery({ callbackQueryId: cbId, text: 'ID invalide' }) continue } await answerCallbackQuery({ callbackQueryId: cbId, text: 'Traitement…' }) try { const res = await acceptBooking(id) const adminNote = absFrontend ? `\n\nOuvrir l’admin: ${absFrontend}/admin` : '' if (res.ok && res.already === 'accepted') { const details = formatDetails(res.booking) await editMessageText({ chatId, messageId, text: `✅ Déjà accepté.${details}${adminNote}` }) } else if (res.ok && res.already === 'refused') { const details = formatDetails(res.booking) await editMessageText({ chatId, messageId, text: `⛔️ Déjà refusé.${details}${adminNote}` }) } else if (res.ok) { const details = formatDetails(res.booking) await editMessageText({ chatId, messageId, text: `✅ Demande acceptée. Un e‑mail de confirmation a été envoyé.${details}${adminNote}` }) } else if (res.reason === 'conflict') { const details = res.booking ? formatDetails(res.booking) : '' await editMessageText({ chatId, messageId, text: `❌ Conflit détecté avec une autre réservation acceptée.${details}${adminNote}` }) } else if (res.reason === 'not_found') { await editMessageText({ chatId, messageId, text: '❓ Demande introuvable.' }) } else { await editMessageText({ chatId, messageId, text: '⚠️ Impossible de traiter la demande.' }) } } catch (e) { await editMessageText({ chatId, messageId, text: '⚠️ Erreur interne lors du traitement.' }) } } } } catch (e) { console.warn('Telegram polling error:', e?.message || e) // brief backoff await new Promise(r => setTimeout(r, 2000)) } // continue loop setImmediate(loop) })() }