/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ 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'; import logger from '../../services/logger.js'; import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js'; const RATE_LIMIT_INTERVAL = 1000; const THROTTLE_MAX_IDLE_MS = RATE_LIMIT_INTERVAL + 2000; const chatThrottleMap = new Map(); /** * Removes stale throttled call entries to keep memory bounded. * An entry is stale when no API call has fired for longer than THROTTLE_MAX_IDLE_MS. */ function cleanupOldThrottles() { const now = Date.now(); for (const [chatId, chatThrottle] of chatThrottleMap.entries()) { if (now - chatThrottle.lastUsedAt > THROTTLE_MAX_IDLE_MS) 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. * `lastUsedAt` is refreshed on every actual API call so that the idle window * starts from the last fired call, not from when send() was invoked. * * @param {string|number} chatId * @param {Function} call - async function (endpoint: string, body: any) => Promise * @returns {Function} */ function getThrottled(chatId, call) { cleanupOldThrottles(); const existing = chatThrottleMap.get(chatId); if (existing) { existing.lastUsedAt = Date.now(); return existing.throttled; } const entry = { lastUsedAt: Date.now(), throttled: null }; chatThrottleMap.set(chatId, entry); entry.throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(async (endpoint, body) => { const e = chatThrottleMap.get(chatId); if (e) e.lastUsedAt = Date.now(); return call(endpoint, body); }); return entry.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 HTML-formatted message body. * Suitable for both sendMessage (uncapped) and sendPhoto captions (caller must slice to 1024). * * @param {string} jobName * @param {string} serviceName * @param {Object} o - Listing object * @param {string} [baseUrl] * @returns {string} */ function buildHtmlBody(jobName, serviceName, o, baseUrl) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const fredyLink = baseUrl && o.id ? `\nOpen in Fredy` : ''; return ( `${escapeHtml(jobName)} (${escapeHtml(serviceName)})\n` + `${escapeHtml(title)}\n` + `${escapeHtml(meta)}${fredyLink}` ); } /** * Build a plain-text Telegram photo caption (max 4096 characters). * Meta appears before the link so the most relevant info is visible within the cap. * * @param {string} jobName * @param {string} serviceName * @param {Object} o - Listing object * @param {string} [baseUrl] * @returns {string} */ function buildPlainCaption(jobName, serviceName, o, baseUrl) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${o.id}` : ''; return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096); } /** * Build a plain-text Telegram message body. * Link appears early so it is tappable without scrolling. * * @param {string} jobName * @param {string} serviceName * @param {Object} o - Listing object * @param {string} [baseUrl] * @returns {string} */ function buildPlainText(jobName, serviceName, o, baseUrl) { const title = shorten((o.title || '').replace(/\*/g, ''), 90); const meta = [o.address, o.price, o.size].filter(Boolean).join(' | '); const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/#/listings/listing/${o.id}` : ''; return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`; } /** * Create the raw Telegram API caller for a given bot token. * Handles JSON and multipart (FormData) bodies. * * @param {string} token - Telegram bot token. * @param {string} jobName - Used in error messages. * @returns {(endpoint: string, body: object|FormData) => Promise} */ function makeTelegramCaller(token, jobName) { return async function (endpoint, body) { const isFormData = body instanceof FormData; const opts = isFormData ? { method: 'post', body } : { method: 'post', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }; const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, opts); if (!res.ok) { const errorBody = await res.text(); throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`); } return res; }; } /** * Send a single listing to a single Telegram chat, with photo-then-text fallback. * * @param {Function} throttledCall - Throttled Telegram API caller for this chat. * @param {Object} listing - Listing object. * @param {string|number} chatId * @param {Object} opts * @param {string} opts.jobName * @param {string} opts.serviceName * @param {string} opts.baseUrl * @param {boolean} opts.plainText * @param {number|undefined} opts.message_thread_id * @returns {Promise} */ async function sendListingToChat( throttledCall, listing, chatId, { jobName, serviceName, baseUrl, plainText, message_thread_id }, ) { const img = normalizeImageUrl(listing.image); const textPayload = { chat_id: chatId, text: plainText ? buildPlainText(jobName, serviceName, listing, baseUrl) : buildHtmlBody(jobName, serviceName, listing, baseUrl), ...(plainText ? {} : { parse_mode: 'HTML' }), disable_web_page_preview: true, ...(message_thread_id ? { message_thread_id } : {}), }; if (!img) { return throttledCall('sendMessage', textPayload).catch((e) => { logger.error(`Error sending message to Telegram: ${e.message}`); }); } const caption = plainText ? buildPlainCaption(jobName, serviceName, listing, baseUrl) : buildHtmlBody(jobName, serviceName, listing, baseUrl).slice(0, 1024); const parseMode = plainText ? undefined : 'HTML'; // .webp URLs (Immowelt/Cloudimage) fail Telegram's URL-based sendPhoto with // "failed to get HTTP URL content". Upload the bytes via multipart instead. const photoCall = shouldUseMultipart(img) ? buildPhotoFormData({ chatId, imageUrl: img, caption, parseMode, messageThreadId: message_thread_id }).then((fd) => throttledCall('sendPhoto', fd), ) : throttledCall('sendPhoto', { chat_id: chatId, photo: img, caption, ...(parseMode ? { parse_mode: parseMode } : {}), ...(message_thread_id ? { message_thread_id } : {}), }); return photoCall.catch(async (e) => { logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`); return throttledCall('sendMessage', textPayload).catch((e) => { logger.error(`Error sending message to Telegram: ${e.message}`); throw e; }); }); } /** * 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, baseUrl }) => { 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, messageThreadId, plainText } = adapterCfg.fields; if (!token || !chatId) { throw new Error("Telegram 'token' and 'chatId' must be provided in notification config"); } const chatIds = String(chatId) .split(',') .map((s) => s.trim()) .filter(Boolean); // Optional Telegram topic/thread support (supergroups) let message_thread_id; if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') { const n = Number(messageThreadId); if (Number.isInteger(n) && n > 0) { message_thread_id = n; } else { logger.warn( `Telegram adapter: 'messageThreadId' is invalid ('${messageThreadId}'). It must be a positive integer. Ignoring.`, ); } } const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]); const allPromises = chatIds.flatMap((id) => { const caller = makeTelegramCaller(token, jobName); const throttledCall = getThrottled(id, caller); const opts = { jobName, serviceName, baseUrl, plainText, message_thread_id }; return newListings.map((listing) => sendListingToChat(throttledCall, listing, id, opts)); }); return Promise.all(allPromises); }; /** * 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},messageThreadId?:{type:string,label:string,description:string}}}} */ 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. Separate multiple IDs with commas to notify several recipients (e.g. 123456789, 987654321).', }, messageThreadId: { type: 'text', optional: true, label: 'Message Thread Id (optional)', description: 'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.', }, plainText: { type: 'boolean', optional: true, label: 'Send as plain text', description: 'Send messages as plain text instead of HTML formatted.', }, }, };