import { markdown2Html } from '../../services/markdown.js'; import { getJob } from '../../services/storage/jobStorage.js'; import fetch from 'node-fetch'; import pThrottle from 'p-throttle'; import { normalizeImageUrl } from '../../utils.js'; const RATE_LIMIT_INTERVAL = 1000; const chatThrottleMap = new Map(); function cleanupOldThrottles() { const now = Date.now(); const maxAge = RATE_LIMIT_INTERVAL + 1000; const toBeDeleted = []; for (const [chatId, chatThrottle] of chatThrottleMap.entries()) { if (now - chatThrottle.lastUsedAt > maxAge) toBeDeleted.push(chatId); } for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId); } function getThrottled(chatId, call) { cleanupOldThrottles(); const now = Date.now(); const chatThrottle = chatThrottleMap.get(chatId); if (chatThrottle) { chatThrottle.lastUsedAt = now; return chatThrottle.throttled; } const throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(call); chatThrottleMap.set(chatId, { lastUsedAt: now, throttled }); return throttled; } function shorten(str, len = 90) { if (!str) return ''; return str.length > len ? str.substring(0, len).trim() + '...' : str; } function escapeHtml(s = '') { return s.replace(/&/g, '&').replace(//g, '>'); } function buildCaption(jobName, serviceName, o) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); return `${escapeHtml(jobName)} (${escapeHtml(serviceName)})\n${escapeHtml(title)}\n${escapeHtml(meta)}`.slice(0, 1024); } function buildText(jobName, serviceName, o) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); return ( `${escapeHtml(jobName)} (${escapeHtml(serviceName)})\n` + `${escapeHtml(title)}\n` + `${escapeHtml(meta)}` ); } export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields; const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; const throttledCall = getThrottled(chatId, async function (endpoint, body) { const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, { method: 'post', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, }); return res; }); const promises = newListings.map(async (o) => { const img = normalizeImageUrl(o.image); const textPayload = { chat_id: chatId, text: buildText(jobName, serviceName, o), parse_mode: 'HTML', disable_web_page_preview: true, }; if (!img) { return throttledCall('sendMessage', textPayload); } try { return await throttledCall('sendPhoto', { chat_id: chatId, photo: img, caption: buildCaption(jobName, serviceName, o), parse_mode: 'HTML', }); } catch (e) { // If we see a timeout due to sending an image, try sending it without if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) { return throttledCall('sendMessage', textPayload); } throw e; } }); return Promise.all(promises); }; export const config = { id: 'telegram', name: 'Telegram', readme: markdown2Html('lib/notification/adapter/telegram.md'), description: 'Fredy will send new listings to your mobile, using Telegram.', fields: { token: { type: 'text', label: 'Token', description: 'The token needed to access this service.', }, chatId: { type: 'chatId', label: 'Chat Id', description: 'The chat id to send messages to you.', }, }, };