mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
@@ -9,6 +9,7 @@ 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 chatThrottleMap = new Map();
|
||||
@@ -177,11 +178,13 @@ export const send = ({ serviceName, newListings = [], notificationConfig, 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' },
|
||||
});
|
||||
// FormData (multipart) vs JSON. node-fetch sets its own multipart boundary
|
||||
// header, so we must NOT supply Content-Type ourselves in that case.
|
||||
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();
|
||||
@@ -208,16 +211,28 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
});
|
||||
}
|
||||
|
||||
return await throttledCall('sendPhoto', {
|
||||
chat_id: chatId,
|
||||
photo: img,
|
||||
caption: plainText
|
||||
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
||||
: buildCaption(jobName, serviceName, o, baseUrl),
|
||||
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||
...(message_thread_id ? { message_thread_id } : {}),
|
||||
}).catch(async (e) => {
|
||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||
const caption = plainText
|
||||
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
||||
: buildCaption(jobName, serviceName, o, baseUrl);
|
||||
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;
|
||||
// the rendered chat message is identical.
|
||||
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 await photoCall.catch(async (e) => {
|
||||
logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||
throw e;
|
||||
|
||||
106
lib/notification/adapter/telegramPhotoUploader.js
Normal file
106
lib/notification/adapter/telegramPhotoUploader.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helpers for sending photos to Telegram via `multipart/form-data` instead of
|
||||
* the HTTP-URL path. Used when the URL is one that Telegram's URL-fetcher will
|
||||
* reject - notably `.webp` images from Cloudimage (mms.immowelt.de), which
|
||||
* Telegram refuses with "Bad Request: failed to get HTTP URL content".
|
||||
*
|
||||
* The HTTP-URL path is faster and is still the default in telegram.js; this
|
||||
* module is the fallback for URLs whose extension makes Telegram fail.
|
||||
*/
|
||||
|
||||
/** Telegram's sendPhoto limit when uploading bytes via multipart/form-data. */
|
||||
const TELEGRAM_MULTIPART_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Accept header used when re-fetching the image ourselves.
|
||||
* Deliberately excludes `image/webp` so CDNs that content-negotiate
|
||||
* (like Cloudimage on mms.immowelt.de) transcode WEBP to JPEG. */
|
||||
const NON_WEBP_ACCEPT = 'image/jpeg,image/png,image/*;q=0.8';
|
||||
|
||||
/**
|
||||
* Returns true if the URL's path ends in a `.webp` extension. Such URLs need
|
||||
* multipart upload because Telegram identifies media types from the URL path
|
||||
* and rejects `.webp` in sendPhoto via HTTP URL.
|
||||
*
|
||||
* Conservative: returns false for null/empty/non-string input, malformed URLs,
|
||||
* and non-https schemes.
|
||||
*
|
||||
* @param {string|null|undefined} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldUseMultipart(url) {
|
||||
if (typeof url !== 'string' || url.length === 0) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'https:') return false;
|
||||
return /\.webp$/i.test(parsed.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an image from `imageUrl` and build a `FormData` body suitable for
|
||||
* POSTing to `https://api.telegram.org/bot<token>/sendPhoto`.
|
||||
*
|
||||
* - Sends an `Accept` header that excludes `image/webp` so origin/CDN servers
|
||||
* that content-negotiate return JPEG bytes.
|
||||
* - Rejects images larger than Telegram's 10 MB multipart limit, both
|
||||
* advertised via `Content-Length` and (defensively) after download.
|
||||
* - The `photo` field is named with a `.jpg` extension because Telegram
|
||||
* identifies file type from the filename.
|
||||
*
|
||||
* Throws if the image fetch fails, the size limit is exceeded, or the URL is
|
||||
* unreachable. The caller is responsible for catching and falling back.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string|number} args.chatId
|
||||
* @param {string} args.imageUrl
|
||||
* @param {string} args.caption
|
||||
* @param {string} [args.parseMode] - Telegram parse_mode, e.g. 'HTML'.
|
||||
* @param {number} [args.messageThreadId] - Telegram supergroup topic id.
|
||||
* @returns {Promise<FormData>}
|
||||
*/
|
||||
export async function buildPhotoFormData({ chatId, imageUrl, caption, parseMode, messageThreadId }) {
|
||||
const res = await fetch(imageUrl, {
|
||||
method: 'GET',
|
||||
headers: { Accept: NON_WEBP_ACCEPT },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch image for multipart upload (${res.status}): ${imageUrl}`);
|
||||
}
|
||||
|
||||
const advertised = Number(res.headers.get('content-length'));
|
||||
if (Number.isFinite(advertised) && advertised > TELEGRAM_MULTIPART_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Image exceeds Telegram multipart size limit (advertised ${advertised} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buf = await res.arrayBuffer();
|
||||
if (buf.byteLength > TELEGRAM_MULTIPART_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Image exceeds Telegram multipart size limit (downloaded ${buf.byteLength} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Telegram identifies the media type from the filename extension. We always
|
||||
// upload as .jpg because the Accept header forces JPEG bytes from CDNs that
|
||||
// honor it; for the rare CDN that ignores Accept and still returns WEBP, the
|
||||
// .jpg filename is a small lie but Telegram's image pipeline accepts it.
|
||||
const blob = new Blob([buf], { type: 'image/jpeg' });
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('chat_id', String(chatId));
|
||||
fd.append('caption', caption);
|
||||
if (parseMode) fd.append('parse_mode', parseMode);
|
||||
if (messageThreadId != null) fd.append('message_thread_id', String(messageThreadId));
|
||||
fd.append('photo', blob, 'photo.jpg');
|
||||
return fd;
|
||||
}
|
||||
@@ -141,6 +141,43 @@ const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
||||
'barrierefreie-wohnung-mieten': { equipment: ['handicappedaccessible'] },
|
||||
};
|
||||
|
||||
// SEO-optimized rental paths used by the ImmoScout web UI when the user
|
||||
// configures a maximum warmrent. Example: "wohnung-bis-800-euro-warm" means
|
||||
// "apartment for rent up to 800 EUR warmrent". The web UI generates these
|
||||
// paths instead of explicit `price` / `pricetype` query parameters.
|
||||
// Note: only the warmrent variant uses an SEO slug; max coldrent searches
|
||||
// use the regular "wohnung-mieten" path with explicit `price` and
|
||||
// `pricetype=rentpermonth` query params, which the existing translator
|
||||
// already handles.
|
||||
const SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE = {
|
||||
wohnung: 'apartmentrent',
|
||||
haus: 'houserent',
|
||||
};
|
||||
const SEO_MAX_WARMRENT_PATH_PATTERN = /^(?<type>wohnung|haus)-bis-(?<price>\d+)-euro-warm$/;
|
||||
|
||||
/**
|
||||
* Parses SEO-optimized ImmoScout web paths that encode a maximum warmrent, such
|
||||
* as "wohnung-bis-800-euro-warm". Returns the corresponding mobile API real
|
||||
* estate type and the implicit price/pricetype parameters, or null if the path
|
||||
* does not match the known SEO max-warmrent pattern.
|
||||
*
|
||||
* @param {string} realTypeKey The last segment of the URL path.
|
||||
* @returns {{ realType: string, additionalParams: Record<string, string> } | null}
|
||||
*/
|
||||
function parseSeoMaxWarmrentPath(realTypeKey) {
|
||||
const match = realTypeKey.match(SEO_MAX_WARMRENT_PATH_PATTERN);
|
||||
if (!match) return null;
|
||||
|
||||
const { type, price } = match.groups;
|
||||
return {
|
||||
realType: SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE[type],
|
||||
additionalParams: {
|
||||
price: `-${price}`,
|
||||
pricetype: 'calculatedtotalrent',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function convertWebToMobile(webUrl) {
|
||||
let url;
|
||||
try {
|
||||
@@ -164,7 +201,14 @@ export function convertWebToMobile(webUrl) {
|
||||
additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey];
|
||||
realType = REAL_ESTATE_TYPE['wohnung-mieten'];
|
||||
} else {
|
||||
throw new Error(`Real estate type not found: ${realTypeKey}`);
|
||||
// Test for SEO max-warmrent path, e.g. "wohnung-bis-800-euro-warm"
|
||||
const seoMaxWarmrent = parseSeoMaxWarmrentPath(realTypeKey);
|
||||
if (seoMaxWarmrent) {
|
||||
realType = seoMaxWarmrent.realType;
|
||||
additionalParamsFromWebPath = seoMaxWarmrent.additionalParams;
|
||||
} else {
|
||||
throw new Error(`Real estate type not found: ${realTypeKey}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user