feat: add telegram bot
This commit is contained in:
parent
deb21fbf42
commit
30be7e5246
6 changed files with 555 additions and 18 deletions
|
|
@ -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": "<YYYY-MM-DD within 31 days>",
|
||||
"start_time": "10:00",
|
||||
"end_date": "<same day or within 31 days>",
|
||||
"end_time": "12:00",
|
||||
"name": "Association Test",
|
||||
"email": "recipient@example.com"
|
||||
}
|
||||
|
||||
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 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"status":"accepted"}'
|
||||
PATCH http://localhost:3000/api/bookings/<ID>/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.
|
||||
|
|
|
|||
42
backend/scripts/test-postal-local.js
Normal file
42
backend/scripts/test-postal-local.js
Normal 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)
|
||||
})
|
||||
|
||||
35
backend/scripts/test-telegram-local.js
Normal file
35
backend/scripts/test-telegram-local.js
Normal 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)
|
||||
})
|
||||
|
||||
|
|
@ -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 = `🚲 <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) {
|
||||
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))
|
||||
})()
|
||||
|
|
|
|||
104
backend/telegram.js
Normal file
104
backend/telegram.js
Normal 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
292
backend/telegramBot.js
Normal 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('&','&').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)
|
||||
})()
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue