allowing multiple chat id's for telegram

This commit is contained in:
orangecoding
2026-06-03 09:46:56 +02:00
parent b3300169fa
commit 322ae199b0
3 changed files with 125 additions and 54 deletions

View File

@@ -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',

View File

@@ -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 bots 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)

View File

@@ -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(() =>