292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
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 `\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 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 = `🔔 <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 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)
|
||
})()
|
||
}
|