agep-cargo/backend/telegramBot.js
2025-10-23 16:35:54 +02:00

292 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
})()
}