mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
312 lines
11 KiB
JavaScript
312 lines
11 KiB
JavaScript
/*
|
|
* 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<Response>
|
|
* @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, '<').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 ? `\n<a href='${escapeHtml(`${baseUrl}/#/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
|
|
return (
|
|
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
|
|
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\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<Response>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<Object>} params.newListings - Array of new listing objects.
|
|
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
|
|
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
|
* @returns {Promise<Array<Response>>} 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.',
|
|
},
|
|
},
|
|
};
|