From 30be7e5246edf19dcb814417705de063aeb9fdfd Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Thu, 23 Oct 2025 16:35:54 +0200 Subject: [PATCH] feat: add telegram bot --- backend/README.md | 53 +++-- backend/scripts/test-postal-local.js | 42 ++++ backend/scripts/test-telegram-local.js | 35 +++ backend/server.js | 47 +++- backend/telegram.js | 104 +++++++++ backend/telegramBot.js | 292 +++++++++++++++++++++++++ 6 files changed, 555 insertions(+), 18 deletions(-) create mode 100644 backend/scripts/test-postal-local.js create mode 100644 backend/scripts/test-telegram-local.js create mode 100644 backend/telegram.js create mode 100644 backend/telegramBot.js diff --git a/backend/README.md b/backend/README.md index cd3b17a..8c25fda 100644 --- a/backend/README.md +++ b/backend/README.md @@ -50,24 +50,49 @@ Notes: Quick test of email sending +Use a date within the next 31 days (inclusive) to satisfy the booking window restriction. + 1) Create a pending booking (adjust dates and recipient): - curl -sS -X POST http://localhost:3000/api/bookings \ - -H 'Content-Type: application/json' \ - -d '{ - "bike_types": [1000], - "start_date": "2025-10-20", - "start_time": "10:00", - "end_date": "2025-10-20", - "end_time": "12:00", - "name": "Association Test", - "email": "recipient@example.com" - }' + POST http://localhost:3000/api/bookings + Content-Type: application/json + { + "bike_types": [1000], + "start_date": "", + "start_time": "10:00", + "end_date": "", + "end_time": "12:00", + "name": "Association Test", + "email": "recipient@example.com" + } 2) Accept the booking (replace by the returned id); this triggers the Postal email: - curl -sS -X PATCH http://localhost:3000/api/bookings//status \ - -H 'Content-Type: application/json' \ - -d '{"status":"accepted"}' + PATCH http://localhost:3000/api/bookings//status + Content-Type: application/json + { + "status": "accepted" + } If Postal is configured correctly, a confirmation email will be sent in the background. Check your Postal logs if you need to diagnose delivery. + +Telegram notifications + +The backend can notify a Telegram chat or group whenever a new booking request is created. + +Configuration (in .env): + +- TELEGRAM_BOT_TOKEN: Bot token obtained from @BotFather +- TELEGRAM_CHAT_ID: Chat ID to send messages to. For groups/supergroups this is often a negative number like -1001234567890. You can provide multiple IDs separated by commas or semicolons to broadcast to several chats. +- FRONTEND_URL: Optional. Base URL of your frontend (must be an absolute http/https URL). When set, the Telegram message includes an "Ouvrir admin" button linking to `${FRONTEND_URL}/admin`. If not set or not absolute, the admin button is omitted to avoid Telegram URL errors. + +Behavior: +- On POST /api/bookings (new request), a message is posted with the association name, requested period, and selected cargobikes. +- Messages use Telegram's HTML parse mode. The format is defined in server.js where the msg string is constructed. + +Local smoke test (no real network): +- Run scripts/test-telegram-local.js to see the constructed payload and a mocked success response. + +Notes: +- If the Telegram env variables are not set, notifications are skipped and a warning is logged; booking creation is not interrupted. +- Ensure the bot is added to the target group and the bot has permission to send messages. diff --git a/backend/scripts/test-postal-local.js b/backend/scripts/test-postal-local.js new file mode 100644 index 0000000..3fcb044 --- /dev/null +++ b/backend/scripts/test-postal-local.js @@ -0,0 +1,42 @@ +// Local smoke test for Postal client without real network +// Mocks fetch to capture the request and return success +import { sendPostalEmail } from '../postal.js' + +// Minimal env to pass config checks +process.env.POSTAL_URL = 'https://postal.example.com' +process.env.POSTAL_API_KEY = 'test_api_key' +process.env.POSTAL_FROM = 'no-reply@example.com' +process.env.POSTAL_FROM_NAME = 'AGEP Cargobike' +process.env.POSTAL_REPLY_TO = 'contact@example.com' + +// Mock global fetch +globalThis.fetch = async (url, init) => { + console.log('FETCH URL:', url) + const body = JSON.parse(init.body) + console.log('FETCH PAYLOAD:', body) + return { + ok: true, + status: 200, + async json() { + return { status: 'success', data: { message_id: 'fake-123' } } + }, + } +} + +async function main() { + const res = await sendPostalEmail({ + to: ['recipient1@example.com', 'recipient2@example.com'], + subject: 'Test subject', + text: 'Plain text body', + html: '

HTML body

', + cc: 'cc1@example.com; cc2@example.com', + bcc: 'bcc@example.com', + }) + console.log('Postal client returned:', res) +} + +main().catch((e) => { + console.error('Test failed:', e) + process.exit(1) +}) + diff --git a/backend/scripts/test-telegram-local.js b/backend/scripts/test-telegram-local.js new file mode 100644 index 0000000..7208d71 --- /dev/null +++ b/backend/scripts/test-telegram-local.js @@ -0,0 +1,35 @@ +// Local smoke test for Telegram client without real network +// Mocks fetch to capture the request and return success +import { sendTelegramMessage } from '../telegram.js' + +// Minimal env to pass config checks +process.env.TELEGRAM_BOT_TOKEN = '123456:ABC-DEF_fake_token' +process.env.TELEGRAM_CHAT_ID = '123456789' // can be negative for groups e.g. -1001234567890 + +// Mock global fetch +globalThis.fetch = async (url, init) => { + console.log('FETCH URL:', url) + const body = JSON.parse(init.body) + console.log('FETCH PAYLOAD:', body) + return { + ok: true, + status: 200, + async json() { + return { ok: true, result: { message_id: 42 } } + }, + } +} + +async function main() { + const res = await sendTelegramMessage({ + text: '🚲 Nouvelle demande de test\nAssociation: Demo\nPériode: 01/11/2025 10:00 → 12:00', + parseMode: 'HTML', + }) + console.log('Telegram client returned:', res) +} + +main().catch((e) => { + console.error('Test failed:', e) + process.exit(1) +}) + diff --git a/backend/server.js b/backend/server.js index 523546e..e465fc0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,8 @@ import cors from 'cors' import dotenv from 'dotenv' import { pool } from './db.js' import { sendPostalEmail } from './postal.js' +import { sendTelegramMessage } from './telegram.js' +import { startTelegramBot } from './telegramBot.js' dotenv.config() @@ -132,7 +134,44 @@ app.post('/api/bookings', async (req, res) => { VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *` const values = [single, multi.length ? multi.join(',') : null, start_date, start_time || null, end_date, end_time || null, name, email, 'pending'] const result = await pool.query(text, values) + + // Respond ASAP res.status(201).json({ booking: sanitizeBooking(result.rows[0]) }) + + // Fire-and-forget: Telegram notify a new request if configured + try { + const b = sanitizeBooking(result.rows[0]) + const bikeList = (() => { + const parts = [] + if (Number.isFinite(b.bike_type)) parts.push(String(b.bike_type)) + if (typeof b.bike_types === 'string' && b.bike_types.trim()) { + parts.push(...b.bike_types.split(',').map(s => s.trim()).filter(Boolean)) + } + return parts.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}` + })() + // Build admin URL only if FRONTEND_URL is absolute (http/https) + const base = String(process.env.FRONTEND_URL || '').trim() + const hasAbs = /^https?:\/\//i.test(base) + const adminUrl = hasAbs ? base.replace(/\/$/, '') + '/admin' : null + const msg = `🚲 Nouvelle demande de cargobike\nAssociation: ${escapeHtml(b.name)}\nPériode: ${escapeHtml(rangeLabel)}\nCargobike(s): ${escapeHtml(bikeList || '—')}` + const buttons = [ { text: '✅ Accepter', callback_data: `accept:${b.id}` } ] + if (adminUrl) buttons.push({ text: '📋 Ouvrir admin', url: adminUrl }) + const replyMarkup = { inline_keyboard: [ buttons ] } + sendTelegramMessage({ text: msg, parseMode: 'HTML', replyMarkup }).catch((e) => { + console.warn('Telegram send failed:', e?.message || e) + }) + } catch (e) { + // Missing config or other error; do not interrupt request flow + console.warn('Telegram notify skipped:', e?.message || e) + } } catch (err) { console.error(err) res.status(500).json({ error: 'Server error' }) @@ -178,11 +217,9 @@ app.patch('/api/bookings/:id/status', async (req, res) => { // Fire-and-forget: if newly accepted, send confirmation email (do not block response) if (status === 'accepted' && currStatus !== 'accepted') { - try { - await sendAcceptanceEmail(updated) - } catch (e) { + sendAcceptanceEmail(updated).catch((e) => { console.warn(`Postal email send failed for booking #${id}:`, e?.message || e) - } + }) } } catch (err) { console.error(err) @@ -401,4 +438,6 @@ const port = process.env.PORT || 3000 ;(async () => { await ensureTables() app.listen(port, () => console.log(`Backend listening on port ${port}`)) + // Start Telegram bot polling (if configured) + startTelegramBot().catch((e) => console.warn('Telegram bot not started:', e?.message || e)) })() diff --git a/backend/telegram.js b/backend/telegram.js new file mode 100644 index 0000000..3f2f826 --- /dev/null +++ b/backend/telegram.js @@ -0,0 +1,104 @@ +// Lightweight Telegram sender +// Sends messages via Telegram Bot API: https://api.telegram.org/bot/sendMessage +// Requires env: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID (single or comma/semicolon separated) + +import dotenv from 'dotenv' +dotenv.config() + +let _fetch = null + +async function getFetch() { + if (typeof globalThis.fetch === 'function') return globalThis.fetch + if (_fetch) return _fetch + try { + const mod = await import('node-fetch') + _fetch = mod.default + return _fetch + } catch (e) { + throw new Error('fetch is not available and node-fetch is not installed. Please install node-fetch or use Node 18+.') + } +} + +function ensureArray(v) { + if (!v) return [] + if (Array.isArray(v)) return v + if (typeof v === 'string') return v.split(/[;,]/).map(s => s.trim()).filter(Boolean) + return [String(v)] +} + +export async function sendTelegramMessage({ text, parseMode = 'HTML', disableWebPagePreview = true, chatIds = null, replyMarkup = null }) { + const token = process.env.TELEGRAM_BOT_TOKEN || '' + const rawChatIds = chatIds || process.env.TELEGRAM_CHAT_ID || '' + const ids = ensureArray(rawChatIds) + + if (!token || ids.length === 0) { + throw new Error('Telegram configuration missing: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are required') + } + + const fetchImpl = await getFetch() + const base = `https://api.telegram.org/bot${token}` + + const payloadBase = { + text: String(text || ''), + parse_mode: parseMode || undefined, + disable_web_page_preview: Boolean(disableWebPagePreview), + } + if (replyMarkup) payloadBase.reply_markup = replyMarkup + + const results = [] + for (const chat_id of ids) { + const res = await fetchImpl(`${base}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payloadBase, chat_id }), + }) + let data + try { + data = await res.json() + } catch (e) { + throw new Error(`Telegram response parse error: HTTP ${res.status}`) + } + if (!res.ok || !data?.ok) { + const errMsg = data?.description || JSON.stringify(data) + throw new Error(`Telegram send failed: ${errMsg}`) + } + results.push(data) + } + return results +} + +async function apiCall(method, body) { + const token = process.env.TELEGRAM_BOT_TOKEN || '' + if (!token) throw new Error('TELEGRAM_BOT_TOKEN missing') + const fetchImpl = await getFetch() + const base = `https://api.telegram.org/bot${token}` + const res = await fetchImpl(`${base}/${method}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + let data + try { data = await res.json() } catch (e) { throw new Error(`Telegram ${method} parse error: HTTP ${res.status}`) } + if (!res.ok || !data?.ok) { + throw new Error(`Telegram ${method} failed: ${data?.description || JSON.stringify(data)}`) + } + return data +} + +export async function answerCallbackQuery({ callbackQueryId, text = '', showAlert = false }) { + return apiCall('answerCallbackQuery', { callback_query_id: callbackQueryId, text, show_alert: showAlert }) +} + +export async function editMessageText({ chatId, messageId, text, parseMode = 'HTML', replyMarkup = null, disableWebPagePreview = true }) { + const body = { chat_id: chatId, message_id: messageId, text, parse_mode: parseMode, disable_web_page_preview: disableWebPagePreview } + if (replyMarkup) body.reply_markup = replyMarkup + return apiCall('editMessageText', body) +} + +export async function editMessageReplyMarkup({ chatId, messageId, replyMarkup }) { + return apiCall('editMessageReplyMarkup', { chat_id: chatId, message_id: messageId, reply_markup: replyMarkup }) +} + +export async function getUpdates({ offset = 0, timeout = 30 }) { + return apiCall('getUpdates', { offset, timeout, allowed_updates: ['message', 'callback_query'] }) +} diff --git a/backend/telegramBot.js b/backend/telegramBot.js new file mode 100644 index 0000000..f514a28 --- /dev/null +++ b/backend/telegramBot.js @@ -0,0 +1,292 @@ +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) + })() +}