feat: add telegram bot

This commit is contained in:
Antoine Pelletier 2025-10-23 16:35:54 +02:00
parent deb21fbf42
commit 30be7e5246
6 changed files with 555 additions and 18 deletions

View file

@ -50,24 +50,49 @@ Notes:
Quick test of email sending 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): 1) Create a pending booking (adjust dates and recipient):
curl -sS -X POST http://localhost:3000/api/bookings \ POST http://localhost:3000/api/bookings
-H 'Content-Type: application/json' \ Content-Type: application/json
-d '{ {
"bike_types": [1000], "bike_types": [1000],
"start_date": "2025-10-20", "start_date": "<YYYY-MM-DD within 31 days>",
"start_time": "10:00", "start_time": "10:00",
"end_date": "2025-10-20", "end_date": "<same day or within 31 days>",
"end_time": "12:00", "end_time": "12:00",
"name": "Association Test", "name": "Association Test",
"email": "recipient@example.com" "email": "recipient@example.com"
}' }
2) Accept the booking (replace <ID> by the returned id); this triggers the Postal email: 2) Accept the booking (replace <ID> by the returned id); this triggers the Postal email:
curl -sS -X PATCH http://localhost:3000/api/bookings/<ID>/status \ PATCH http://localhost:3000/api/bookings/<ID>/status
-H 'Content-Type: application/json' \ Content-Type: application/json
-d '{"status":"accepted"}' {
"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. 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.

View file

@ -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: '<p>HTML body</p>',
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)
})

View file

@ -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: '🚲 <b>Nouvelle demande de test</b>\n<b>Association:</b> Demo\n<b>Période:</b> 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)
})

View file

@ -3,6 +3,8 @@ import cors from 'cors'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { pool } from './db.js' import { pool } from './db.js'
import { sendPostalEmail } from './postal.js' import { sendPostalEmail } from './postal.js'
import { sendTelegramMessage } from './telegram.js'
import { startTelegramBot } from './telegramBot.js'
dotenv.config() dotenv.config()
@ -132,7 +134,44 @@ app.post('/api/bookings', async (req, res) => {
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *` 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 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) const result = await pool.query(text, values)
// Respond ASAP
res.status(201).json({ booking: sanitizeBooking(result.rows[0]) }) 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 = `🚲 <b>Nouvelle demande de cargobike</b>\n<b>Association:</b> ${escapeHtml(b.name)}\n<b>Période:</b> ${escapeHtml(rangeLabel)}\n<b>Cargobike(s):</b> ${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) { } catch (err) {
console.error(err) console.error(err)
res.status(500).json({ error: 'Server error' }) 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) // Fire-and-forget: if newly accepted, send confirmation email (do not block response)
if (status === 'accepted' && currStatus !== 'accepted') { if (status === 'accepted' && currStatus !== 'accepted') {
try { sendAcceptanceEmail(updated).catch((e) => {
await sendAcceptanceEmail(updated)
} catch (e) {
console.warn(`Postal email send failed for booking #${id}:`, e?.message || e) console.warn(`Postal email send failed for booking #${id}:`, e?.message || e)
} })
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -401,4 +438,6 @@ const port = process.env.PORT || 3000
;(async () => { ;(async () => {
await ensureTables() await ensureTables()
app.listen(port, () => console.log(`Backend listening on port ${port}`)) 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))
})() })()

104
backend/telegram.js Normal file
View file

@ -0,0 +1,104 @@
// Lightweight Telegram sender
// Sends messages via Telegram Bot API: https://api.telegram.org/bot<token>/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'] })
}

292
backend/telegramBot.js Normal file
View file

@ -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('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')
}
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 `\n<b>Association:</b> ${escapeHtml(b.name || '—')}\n<b>Période:</b> ${escapeHtml(rangeLabel)}\n<b>Cargobike(s):</b> ${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 <p>${escapeHtml(greeting)}</p>\n <p>Votre réservation de cargobike a été <strong>validée</strong>.</p>\n <p><strong>Détails de la réservation</strong><br/>\n • Période: ${escapeHtml(rangeLabel)}<br/>\n • Cargobike(s): ${escapeHtml(bikeList || '—')}\n </p>\n <p>Vous recevrez prochainement des informations complémentaires si nécessaire.</p>\n <p>Cordialement,<br/>Association AGEP</p>\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 ladmin: ${absFrontend}/admin` : ''
await sendTelegramMessage({
text: `📋 ${list.length} demande(s) en attente. Vous pouvez accepter cidessous.${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 = `🔔 <b>Demande #${b.id}</b>\n<b>Association:</b> ${escapeHtml(b.name)}\n<b>Période:</b> ${escapeHtml(rangeLabel)}\n<b>Cargobike(s):</b> ${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 ladmin: ${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 email 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)
})()
}