mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
allowing multiple chat id's for telegram
This commit is contained in:
@@ -161,6 +161,11 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
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)
|
// Optional Telegram topic/thread support (supergroups)
|
||||||
let message_thread_id;
|
let message_thread_id;
|
||||||
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
|
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
|
||||||
@@ -177,70 +182,78 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
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([]);
|
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
const promises = newListings.map(async (o) => {
|
const allPromises = chatIds.flatMap((id) => {
|
||||||
const img = normalizeImageUrl(o.image);
|
const throttledCall = getThrottled(id, async function (endpoint, body) {
|
||||||
const textPayload = {
|
// FormData (multipart) vs JSON. node-fetch sets its own multipart boundary
|
||||||
chat_id: chatId,
|
// header, so we must NOT supply Content-Type ourselves in that case.
|
||||||
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl),
|
const isFormData = body instanceof FormData;
|
||||||
...(plainText ? {} : { parse_mode: 'HTML' }),
|
const opts = isFormData
|
||||||
disable_web_page_preview: true,
|
? { method: 'post', body }
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
: { 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) {
|
if (!res.ok) {
|
||||||
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
|
const errorBody = await res.text();
|
||||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
|
||||||
});
|
}
|
||||||
}
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
const caption = plainText
|
return newListings.map(async (o) => {
|
||||||
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
const img = normalizeImageUrl(o.image);
|
||||||
: buildCaption(jobName, serviceName, o, baseUrl);
|
const textPayload = {
|
||||||
const parseMode = plainText ? undefined : 'HTML';
|
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
|
if (!img) {
|
||||||
// "failed to get HTTP URL content". Upload the bytes via multipart instead;
|
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
|
||||||
// the rendered chat message is identical.
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
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) => {
|
const caption = plainText
|
||||||
logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
||||||
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
: buildCaption(jobName, serviceName, o, baseUrl);
|
||||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
const parseMode = plainText ? undefined : 'HTML';
|
||||||
throw e;
|
|
||||||
|
// .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: {
|
chatId: {
|
||||||
type: 'chatId',
|
type: 'chatId',
|
||||||
label: 'Chat Id',
|
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: {
|
messageThreadId: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Steps:
|
|||||||
- Private chats: `chat.id` is a positive number
|
- Private chats: `chat.id` is a positive number
|
||||||
- Groups/supergroups: `chat.id` is a negative 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.
|
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)
|
#### Getting the thread ID (this is optional to be used for forum topics)
|
||||||
|
|||||||
@@ -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', () => {
|
describe('telegram send() - config validation', () => {
|
||||||
it('throws when telegram adapter config is missing', () => {
|
it('throws when telegram adapter config is missing', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user