diff --git a/lib/notification/adapter/telegram.js b/lib/notification/adapter/telegram.js index eb2c25e..c1d56d7 100644 --- a/lib/notification/adapter/telegram.js +++ b/lib/notification/adapter/telegram.js @@ -161,6 +161,11 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey 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() !== '') { @@ -177,70 +182,78 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey const job = getJob(jobKey); const jobName = job == null ? jobKey : job.name; - const throttledCall = getThrottled(chatId, async function (endpoint, body) { - // 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(); - throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`); - } - 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 = { - chat_id: chatId, - text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl), - ...(plainText ? {} : { parse_mode: 'HTML' }), - disable_web_page_preview: true, - ...(message_thread_id ? { message_thread_id } : {}), - }; + const allPromises = chatIds.flatMap((id) => { + const throttledCall = getThrottled(id, async function (endpoint, body) { + // 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 (!img) { - return await throttledCall('sendMessage', textPayload).catch(async (e) => { - logger.error(`Error sending message to Telegram: ${e.message}`); - }); - } + if (!res.ok) { + const errorBody = await res.text(); + throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`); + } + return res; + }); - const caption = plainText - ? buildCaptionPlain(jobName, serviceName, o, baseUrl) - : buildCaption(jobName, serviceName, o, baseUrl); - const parseMode = plainText ? undefined : 'HTML'; + return newListings.map(async (o) => { + const img = normalizeImageUrl(o.image); + const textPayload = { + chat_id: id, + text: plainText + ? buildTextPlain(jobName, serviceName, o, baseUrl) + : buildText(jobName, serviceName, o, baseUrl), + ...(plainText ? {} : { parse_mode: 'HTML' }), + disable_web_page_preview: true, + ...(message_thread_id ? { message_thread_id } : {}), + }; - // .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 } : {}), + if (!img) { + return await throttledCall('sendMessage', textPayload).catch(async (e) => { + logger.error(`Error sending message to Telegram: ${e.message}`); }); + } - 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; + 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: id, + imageUrl: img, + caption, + parseMode, + messageThreadId: message_thread_id, + }).then((fd) => throttledCall('sendPhoto', fd)) + : throttledCall('sendPhoto', { + chat_id: id, + 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; + }); }); }); }); - return Promise.all(promises); + return Promise.all(allPromises); }; /** @@ -261,7 +274,8 @@ export const config = { chatId: { type: 'chatId', label: 'Chat Id', - description: 'The chat id to send messages to you.', + description: + 'The chat ID to send messages to. Separate multiple IDs with commas to notify several recipients (e.g. 123456789, 987654321).', }, messageThreadId: { type: 'text', diff --git a/lib/notification/adapter/telegram.md b/lib/notification/adapter/telegram.md index 406a5a1..8767909 100644 --- a/lib/notification/adapter/telegram.md +++ b/lib/notification/adapter/telegram.md @@ -21,6 +21,8 @@ Steps: - Private chats: `chat.id` is a positive number - Groups/supergroups: `chat.id` is a negative number +**Multiple recipients:** To notify several users individually, enter a comma-separated list of chat IDs in the Chat Id field, e.g. `123456789, 987654321`. Each recipient receives the same messages and gets its own independent rate-limit window. This avoids having to create a group and add the bot to it. + Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bot’s privacy settings allow it to see group messages when used in groups. #### Getting the thread ID (this is optional to be used for forum topics) diff --git a/test/notification/telegram.test.js b/test/notification/telegram.test.js index 78383b3..37fb208 100644 --- a/test/notification/telegram.test.js +++ b/test/notification/telegram.test.js @@ -335,6 +335,61 @@ describe('telegram send() - mixed batch (regression-safety)', () => { }); }); +describe('telegram send() - multiple chat IDs', () => { + const listing = { + id: '1', + title: 'Flat', + link: 'https://ex.com', + address: 'Berlin', + price: '800', + size: '50', + image: 'https://ex.com/img.jpg', + }; + + it('sends to every chat ID in a comma-separated list', async () => { + mockNodeFetch.mockResolvedValue(jsonOk()); + + await send({ + serviceName: 'immoscout', + newListings: [listing], + notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '111, 222' } }], + jobKey: 'Berlin', + }); + + expect(mockNodeFetch).toHaveBeenCalledTimes(2); + const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body)); + expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['111', '222'])); + }); + + it('trims whitespace around each chat ID', async () => { + mockNodeFetch.mockResolvedValue(jsonOk()); + + await send({ + serviceName: 'immoscout', + newListings: [listing], + notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: ' 333 , 444 ' } }], + jobKey: 'Berlin', + }); + + expect(mockNodeFetch).toHaveBeenCalledTimes(2); + const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body)); + expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['333', '444'])); + }); + + it('sends each listing to each chat ID (N listings × M chats)', async () => { + mockNodeFetch.mockResolvedValue(jsonOk()); + + await send({ + serviceName: 'immoscout', + newListings: [listing, { ...listing, id: '2' }], + notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '555, 666' } }], + jobKey: 'Berlin', + }); + + expect(mockNodeFetch).toHaveBeenCalledTimes(4); + }); +}); + describe('telegram send() - config validation', () => { it('throws when telegram adapter config is missing', () => { expect(() =>