From 57e3d4ddb793849172c677b009c5db8c879b4f00 Mon Sep 17 00:00:00 2001 From: Zerocool <35503951+TonyAntoun@users.noreply.github.com> Date: Wed, 5 Feb 2025 03:06:25 +0200 Subject: [PATCH 1/2] Update telegram.provider.ts - Updated the logic in 'authenticate' to handle both public and private Telegram groups/channels. - Updated the logic in 'getBotId' to handle both public and private Telegram groups/channels. - Updated the logic in 'post' to apply sending local files, include contentType, media handling (captions/groups), link generation (to fix undefined links) --- .../integrations/social/telegram.provider.ts | 176 +++++++++++++----- 1 file changed, 126 insertions(+), 50 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts index e43da809..665ffcfb 100644 --- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts @@ -7,10 +7,15 @@ import { import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import dayjs from 'dayjs'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +//@ts-ignore +import mime from 'mime'; import TelegramBot from 'node-telegram-bot-api'; import { Integration } from '@prisma/client'; const telegramBot = new TelegramBot(process.env.TELEGRAM_TOKEN!); +// Added to support local storage posting +const frontendURL = process.env.FRONTEND_URL || 'http://localhost:5000'; +const mediaStorage = process.env.STORAGE_PROVIDER || 'local'; export class TelegramProvider extends SocialAbstract implements SocialProvider { identifier = 'telegram'; @@ -47,7 +52,7 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { }) { const chat = await telegramBot.getChat(params.code); - console.log(JSON.stringify(chat)) + console.log(JSON.stringify(chat)); if (!chat?.id) { return 'No chat found'; } @@ -55,8 +60,10 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { const photo = !chat?.photo?.big_file_id ? '' : await telegramBot.getFileLink(chat.photo.big_file_id); + + // Modified id to work with chat.username (public groups/channels) or chat.id (private groups/channels) when chat.username is not available return { - id: String(chat.username), + id: String(chat.username ? chat.username : chat.id), name: chat.title!, accessToken: String(chat.id), refreshToken: '', @@ -67,21 +74,29 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { } async getBotId(query: { id?: number; word: string }) { + // Added allowed_updates Ensure only necessary updates are fetched const res = await telegramBot.getUpdates({ ...(query.id ? { offset: query.id } : {}), + allowed_updates: ['message', 'channel_post'], }); - - const chatId = res?.find( - (p) => p?.message?.text === `/connect ${query.word}` - )?.message?.chat?.id; - + //message.text is for groups, channel_post.text is for channels + const match = res.find( + (p) => + (p?.message?.text === `/connect ${query.word}` && + p?.message?.chat?.id) || + (p?.channel_post?.text === `/connect ${query.word}` && + p?.channel_post?.chat?.id) + ); + // get correct chatId based on the channel type + const chatId = match?.message?.chat?.id || match?.channel_post?.chat?.id; + // modified lastChatId to work with any type of channel (private/public groups/channels) return chatId - ? { - chatId, - } + ? { chatId } : res.length > 0 ? { - lastChatId: res?.[res.length - 1]?.message?.chat?.id, + lastChatId: + res?.[res.length - 1]?.message?.chat?.id || + res?.[res.length - 1]?.channel_post?.chat?.id, } : {}; } @@ -92,50 +107,111 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { postDetails: PostDetails[] ): Promise { const ids: PostResponse[] = []; + for (const message of postDetails) { - if ( - (message?.media?.length || 0) === 1 - ) { - const [{ message_id }] = await telegramBot.sendMediaGroup( - accessToken, - message?.media?.map((m) => ({ - type: m.url.indexOf('mp4') > -1 ? 'video' : 'photo', - caption: message.message, - media: m.url, - })) || [] - ); - - ids.push({ - id: message.id, - postId: String(message_id), - releaseURL: `https://t.me/${id}/${message_id}`, - status: 'completed', - }); - } else { - const { message_id } = await telegramBot.sendMessage( - accessToken, - message.message - ); - - ids.push({ - id: message.id, - postId: String(message_id), - releaseURL: `https://t.me/${id}/${message_id}`, - status: 'completed', - }); - - if ((message?.media?.length || 0) > 0) { - await telegramBot.sendMediaGroup( - accessToken, - message?.media?.map((m) => ({ - type: m.url.indexOf('mp4') > -1 ? 'video' : 'photo', - media: m.url, - })) || [] - ); + let messageId: number | null = null; + const mediaFiles = message.media || []; + const text = message.message || ''; + // check if media is local to modify url + const processedMedia = mediaFiles.map((media) => { + let mediaUrl = media.url; + if (mediaStorage === 'local' && mediaUrl.startsWith(frontendURL)) { + mediaUrl = mediaUrl.replace(frontendURL, ''); } + //get mime type to pass contentType to telegram api. + //some photos and videos might not pass telegram api restrictions, so they are sent as documents instead of returning errors + const mimeType = mime.getType(mediaUrl); // Detect MIME type + let mediaType: 'photo' | 'video' | 'document'; + + if (mimeType?.startsWith('image/')) { + mediaType = 'photo'; + } else if (mimeType?.startsWith('video/')) { + mediaType = 'video'; + } else { + mediaType = 'document'; + } + + return { + type: mediaType, + media: mediaUrl, + fileOptions: { + filename: media.url.split('/').pop(), + contentType: mimeType || 'application/octet-stream', + }, + }; + }); + // if there's no media, bot sends a text message only + if (processedMedia.length === 0) { + const response = await telegramBot.sendMessage(accessToken, text); + messageId = response.message_id; + } + // if there's only one media, bot sends the media with the text message as caption + else if (processedMedia.length === 1) { + const media = processedMedia[0]; + const response = + media.type === 'video' + ? await telegramBot.sendVideo( + accessToken, + media.media, + { caption: text }, + media.fileOptions + ) + : media.type === 'photo' + ? await telegramBot.sendPhoto( + accessToken, + media.media, + { caption: text }, + media.fileOptions + ) + : await telegramBot.sendDocument( + accessToken, + media.media, + { caption: text }, + media.fileOptions + ); + messageId = response.message_id; + } + // if there are multiple media, bot sends them as a media group - max 10 media per group - with the text as a caption (if there are more than 1 group, the caption will only be sent with the first group) + else { + const mediaGroups = this.chunkMedia(processedMedia, 10); + for (let i = 0; i < mediaGroups.length; i++) { + const mediaGroup = mediaGroups[i].map((m, index) => ({ + type: m.type === 'document' ? 'document' : m.type, // Documents are not allowed in media groups + media: m.media, + caption: i === 0 && index === 0 ? text : undefined, + })); + + const response = await telegramBot.sendMediaGroup( + accessToken, + mediaGroup + ); + if (i === 0) { + messageId = response[0].message_id; + } + } + } + // for private groups/channels message.id is undefined so the link generated by Postiz will be unusable "https://t.me/c/undefined/16" + // to avoid that, we use accessToken instead of message.id and we generate the link manually removing the -100 from the start. + if (messageId) { + ids.push({ + id: message.id, + postId: String(messageId), + releaseURL: `https://t.me/${ + id !== 'undefined' ? id : `c/${accessToken.replace('-100', '')}` + }/${messageId}`, + status: 'completed', + }); } } return ids; } + // chunkMedia is used to split media into groups of "size". 10 is used here because telegram api allows a maximum of 10 media per group + private chunkMedia(media: { type: string; media: string }[], size: number) { + const result = []; + for (let i = 0; i < media.length; i += size) { + result.push(media.slice(i, i + size)); + } + return result; + } } From a1417a385855fa1cbc38e0f0f9543ef1ec4adb41 Mon Sep 17 00:00:00 2001 From: Zerocool <35503951+TonyAntoun@users.noreply.github.com> Date: Thu, 6 Feb 2025 00:40:43 +0200 Subject: [PATCH 2/2] Update telegram.provider.ts Added the code to delete the user /connect message - IF THE BOT IS ADMIN Added code to check the member status of the bot --- .../integrations/social/telegram.provider.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts index 665ffcfb..79c31076 100644 --- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts @@ -89,6 +89,39 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { ); // get correct chatId based on the channel type const chatId = match?.message?.chat?.id || match?.channel_post?.chat?.id; + + // prevents the code from running while chatId is still undefined to avoid the error 'ETELEGRAM: 400 Bad Request: chat_id is empty'. the code would still work eventually but console spam is not pretty + if (chatId) { + //get the numberic ID of the bot + const botId = (await telegramBot.getMe()).id; + // check if the bot is an admin in the chat + const isAdmin = await this.botIsAdmin(chatId, botId); + // get the messageId of the message that triggered the connection + const connectMessageId = + match?.message?.message_id || match?.channel_post?.message_id; + + if (!isAdmin) { + // alternatively you can replace this with a console.log if you do not want to inform the user of the bot's admin status + telegramBot.sendMessage( + chatId, + "Connection Successful. I don't have admin privileges to delete these messages, please go ahead and remove them yourself." + ); + } else { + // Delete the message that triggered the connection + await telegramBot.deleteMessage(chatId, connectMessageId); + // Send success message to the chat + const successMessage = await telegramBot.sendMessage( + chatId, + 'Connection Successful. Message will be deleted in 10 seconds.' + ); + // Delete the success message after 10 seconds + setTimeout(async () => { + await telegramBot.deleteMessage(chatId, successMessage.message_id); + console.log('Success message deleted.'); + }, 10000); + } + } + // modified lastChatId to work with any type of channel (private/public groups/channels) return chatId ? { chatId } @@ -214,4 +247,23 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { } return result; } + + async botIsAdmin(chatId: number, botId: number): Promise { + try { + const chatMember = await telegramBot.getChatMember(chatId, botId); + + if ( + chatMember.status === 'administrator' || + chatMember.status === 'creator' + ) { + const permissions = chatMember.can_delete_messages; + return !!permissions; // Return true if bot can delete messages + } + + return false; + } catch (error) { + console.error('Error checking bot privileges:', error); + return false; + } + } }