From daa4a7b8f1c3ac1b325b48cf758efc1ab718966f Mon Sep 17 00:00:00 2001 From: orangecoding Date: Sun, 5 Oct 2025 18:53:17 +0200 Subject: [PATCH] refine telegram adapter --- lib/notification/adapter/telegram.js | 71 +++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/notification/adapter/telegram.js b/lib/notification/adapter/telegram.js index b2e3133..139e48e 100644 --- a/lib/notification/adapter/telegram.js +++ b/lib/notification/adapter/telegram.js @@ -8,6 +8,9 @@ import logger from '../../services/logger.js'; const RATE_LIMIT_INTERVAL = 1000; const chatThrottleMap = new Map(); +/** + * Removes stale throttled call entries to keep memory bounded. + */ function cleanupOldThrottles() { const now = Date.now(); const maxAge = RATE_LIMIT_INTERVAL + 1000; @@ -18,6 +21,15 @@ function cleanupOldThrottles() { for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId); } +/** + * Return a throttled wrapper for a chatId to limit Telegram API calls. + * Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat. + * + * @template {Function} T + * @param {string|number} chatId + * @param {T} call - async function (endpoint: string, body: any) => Promise + * @returns {T} + */ function getThrottled(chatId, call) { cleanupOldThrottles(); const now = Date.now(); @@ -31,15 +43,38 @@ function getThrottled(chatId, call) { return throttled; } +/** + * Shorten a string to a maximum length with an ellipsis suffix. + * @param {string} str + * @param {number} [len=90] + * @returns {string} + */ function shorten(str, len = 90) { if (!str) return ''; return str.length > len ? str.substring(0, len).trim() + '...' : str; } +/** + * Escape basic HTML entities for Telegram HTML parse mode. + * @param {string} [s=''] + * @returns {string} + */ function escapeHtml(s = '') { return s.replace(/&/g, '&').replace(//g, '>'); } +/** + * Build a Telegram photo caption (max 1024 characters) using HTML parse mode. + * @param {string} jobName + * @param {string} serviceName + * @param {Object} o - Listing object + * @param {string} [o.title] + * @param {string} [o.address] + * @param {string|number} [o.price] + * @param {string|number} [o.size] + * @param {string} [o.link] + * @returns {string} + */ function buildCaption(jobName, serviceName, o) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); @@ -48,6 +83,13 @@ function buildCaption(jobName, serviceName, o) { )}'>${escapeHtml(title)}\n${escapeHtml(meta)}`.slice(0, 1024); } +/** + * Build a Telegram message text using HTML parse mode. + * @param {string} jobName + * @param {string} serviceName + * @param {Object} o - Listing object + * @returns {string} + */ function buildText(jobName, serviceName, o) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); @@ -58,8 +100,27 @@ function buildText(jobName, serviceName, o) { ); } -export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { - const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields; +/** + * Send new listings to Telegram. + * - Respects per-chat Telegram rate limits using a lightweight throttle cache. + * - Falls back to sendMessage when sendPhoto fails or image is missing. + * + * @param {Object} params + * @param {string} params.serviceName - Name of the crawler/service producing the listings. + * @param {Array} params.newListings - Array of new listing objects. + * @param {Array} params.notificationConfig - Notification adapters configuration array. + * @param {string} params.jobKey - Storage job key to resolve the human readable job name. + * @returns {Promise>} Promise resolving when all send operations complete. + */ +export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => { + const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id); + if (!adapterCfg || !adapterCfg.fields) { + throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`); + } + const { token, chatId } = adapterCfg.fields; + if (!token || !chatId) { + throw new Error("Telegram 'token' and 'chatId' must be provided in notification config"); + } const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; @@ -77,6 +138,8 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) = return res; }); + if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]); + const promises = newListings.map(async (o) => { const img = normalizeImageUrl(o.image); const textPayload = { @@ -109,6 +172,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) = return Promise.all(promises); }; +/** + * Telegram notification adapter configuration schema. + * @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string}}}} + */ export const config = { id: 'telegram', name: 'Telegram',