From ac93af60281d6c7a1cf394adaf213978ccceb5ae Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 2 May 2024 14:05:14 -0700 Subject: [PATCH] feat: Support document file uploads on telegram channel (#9266) --- app/models/channel/telegram.rb | 75 +++------- .../telegram/send_attachments_service.rb | 138 ++++++++++++++++++ spec/models/channel/telegram_spec.rb | 58 +------- .../telegram/send_attachments_service_spec.rb | 114 +++++++++++++++ 4 files changed, 277 insertions(+), 108 deletions(-) create mode 100644 app/services/telegram/send_attachments_service.rb create mode 100644 spec/services/telegram/send_attachments_service_spec.rb diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index f6b97f2d2..7c09fe284 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -33,9 +33,9 @@ class Channel::Telegram < ApplicationRecord end def send_message_on_telegram(message) - return send_message(message) if message.attachments.empty? - - send_attachments(message) + message_id = send_message(message) if message.content.present? + message_id = Telegram::SendAttachmentsService.new(message: message).perform if message.attachments.present? + message_id end def get_telegram_profile_image(user_id) @@ -56,6 +56,23 @@ class Channel::Telegram < ApplicationRecord "https://api.telegram.org/file/bot#{bot_token}/#{response.parsed_response['result']['file_path']}" end + def process_error(message, response) + return unless response.parsed_response['ok'] == false + + # https://github.com/TelegramBotAPI/errors/tree/master/json + message.external_error = "#{response.parsed_response['error_code']}, #{response.parsed_response['description']}" + message.status = :failed + message.save! + end + + def chat_id(message) + message.conversation[:additional_attributes]['chat_id'] + end + + def reply_to_message_id(message) + message.content_attributes['in_reply_to_external_id'] + end + private def ensure_valid_bot_token @@ -77,29 +94,12 @@ class Channel::Telegram < ApplicationRecord errors.add(:bot_token, 'error setting up the webook') unless response.success? end - def chat_id(message) - message.conversation[:additional_attributes]['chat_id'] - end - - def reply_to_message_id(message) - message.content_attributes['in_reply_to_external_id'] - end - def send_message(message) response = message_request(chat_id(message), message.content, reply_markup(message), reply_to_message_id(message)) process_error(message, response) response.parsed_response['result']['message_id'] if response.success? end - def process_error(message, response) - return unless response.parsed_response['ok'] == false - - # https://github.com/TelegramBotAPI/errors/tree/master/json - message.external_error = "#{response.parsed_response['error_code']}, #{response.parsed_response['description']}" - message.status = :failed - message.save! - end - def reply_markup(message) return unless message.content_type == 'input_select' @@ -114,41 +114,6 @@ class Channel::Telegram < ApplicationRecord }.to_json end - def send_attachments(message) - send_message(message) unless message.content.nil? - - telegram_attachments = [] - message.attachments.each do |attachment| - telegram_attachment = {} - telegram_attachment[:type] = attachment_type(attachment[:file_type]) - telegram_attachment[:media] = attachment.download_url - telegram_attachments << telegram_attachment - end - - response = attachments_request(chat_id(message), telegram_attachments, reply_to_message_id(message)) - process_error(message, response) - response.parsed_response['result'].first['message_id'] if response.success? - end - - def attachment_type(file_type) - file_type_mappings = { - 'audio' => 'audio', - 'image' => 'photo', - 'file' => 'document', - 'video' => 'video' - } - file_type_mappings[file_type] - end - - def attachments_request(chat_id, attachments, reply_to_message_id) - HTTParty.post("#{telegram_api_url}/sendMediaGroup", - body: { - chat_id: chat_id, - media: attachments.to_json, - reply_to_message_id: reply_to_message_id - }) - end - def convert_markdown_to_telegram_html(text) # ref: https://core.telegram.org/bots/api#html-style diff --git a/app/services/telegram/send_attachments_service.rb b/app/services/telegram/send_attachments_service.rb new file mode 100644 index 000000000..2f69026b2 --- /dev/null +++ b/app/services/telegram/send_attachments_service.rb @@ -0,0 +1,138 @@ +# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile + +# Media attachments like photos, videos can be clubbed together and sent as a media group +# Audio can be clubbed together and send as a media group, but can't be mixed with other types +# Documents are sent individually + +# We are using `HTTP URL` to send media attachments, telegram will directly download the media from the URL and send it to the user. +# But for documents, we need to send the file as a multipart request. as telegram only support pdf and zip for the download from the URL option. + +# ref: `In sendDocument, sending by URL will currently only work for GIF, PDF and ZIP files.` +# ref: `https://core.telegram.org/bots/api#senddocument` +# ref: `https://core.telegram.org/bots/api#sendmediaGroup + +# The service will terminate if any of the attachment requests fail when the message has multiple attachments +# We will create multiple messages in telegram if the message has multiple attachments (if its documents or mixed media). +class Telegram::SendAttachmentsService + pattr_initialize [:message!] + + def perform + attachment_message_id = nil + + group_attachments_by_type.each do |type, attachments| + attachment_message_id = process_attachments_by_type(type, attachments) + break if attachment_message_id.nil? + end + + attachment_message_id + end + + private + + def process_attachments_by_type(type, attachments) + response = send_attachments(type, attachments) + return extract_attachment_message_id(response) if handle_response(response) + + nil + end + + def send_attachments(type, attachments) + if [:media, :audio].include?(type) + media_group_request(channel.chat_id(message), attachments, channel.reply_to_message_id(message)) + else + send_individual_attachments(attachments) + end + end + + def group_attachments_by_type + attachments_by_type = { media: [], audio: [], document: [] } + + message.attachments.each do |attachment| + type = attachment_type(attachment[:file_type]) + attachment_data = { type: type, media: attachment.download_url, attachment: attachment } + case type + when 'document' + attachments_by_type[:document] << attachment_data + when 'audio' + attachments_by_type[:audio] << attachment_data + when 'photo', 'video' + attachments_by_type[:media] << attachment_data + end + end + + attachments_by_type.reject { |_, v| v.empty? } + end + + def attachment_type(file_type) + { 'audio' => 'audio', 'image' => 'photo', 'file' => 'document', 'video' => 'video' }[file_type] || 'document' + end + + def media_group_request(chat_id, attachments, reply_to_message_id) + HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup", + body: { + chat_id: chat_id, + media: attachments.map { |hash| hash.except(:attachment) }.to_json, + reply_to_message_id: reply_to_message_id + }) + end + + def send_individual_attachments(attachments) + response = nil + attachments.map do |attachment| + response = document_request(channel.chat_id(message), attachment, channel.reply_to_message_id(message)) + break unless handle_response(response) + end + response + end + + def document_request(chat_id, attachment, reply_to_message_id) + temp_file_path = save_attachment_to_tempfile(attachment[:attachment]) + response = send_file(chat_id, temp_file_path, reply_to_message_id) + File.delete(temp_file_path) + response + end + + # Telegram picks up the file name from original field name, so we need to save the file with the original name. + # Hence not using Tempfile here. + def save_attachment_to_tempfile(attachment) + raw_data = attachment.file.download + temp_dir = Rails.root.join('tmp/uploads') + FileUtils.mkdir_p(temp_dir) + temp_file_path = File.join(temp_dir, attachment.file.filename.to_s) + File.write(temp_file_path, raw_data, mode: 'wb') + temp_file_path + end + + def send_file(chat_id, file_path, reply_to_message_id) + File.open(file_path, 'rb') do |file| + HTTParty.post("#{channel.telegram_api_url}/sendDocument", + body: { + chat_id: chat_id, + document: file, + reply_to_message_id: reply_to_message_id + }, + multipart: true) + end + end + + def handle_response(response) + return true if response.success? + + Rails.logger.error "Message Id: #{message.id} - Error sending attachment to telegram: #{response.parsed_response}" + channel.process_error(message, response) + false + end + + def extract_attachment_message_id(response) + return unless response.success? + + result = response.parsed_response['result'] + # response will be an array if the request for media group + # response will be a hash if the request for document + result.is_a?(Array) ? result.first['message_id'] : result['message_id'] + end + + def channel + @channel ||= message.inbox.channel + end +end diff --git a/spec/models/channel/telegram_spec.rb b/spec/models/channel/telegram_spec.rb index 2cc927977..17aa848f6 100644 --- a/spec/models/channel/telegram_spec.rb +++ b/spec/models/channel/telegram_spec.rb @@ -137,68 +137,20 @@ RSpec.describe Channel::Telegram do end end - context 'when a empty message and valid attachments' do + context 'when message contains attachments' do let(:message) do create(:message, message_type: :outgoing, content: nil, conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) end - it 'send image' do - telegram_attachment_response = double + it 'calls send attachment service' do + telegram_attachment_service = double attachment = message.attachments.new(account_id: message.account_id, file_type: :image) attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') - allow(telegram_attachment_response).to receive(:success?).and_return(true) - allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_456' }] }) - allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) + allow(Telegram::SendAttachmentsService).to receive(:new).with(message: message).and_return(telegram_attachment_service) + allow(telegram_attachment_service).to receive(:perform).and_return('telegram_456') expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456') end - - it 'send document' do - telegram_attachment_response = double - attachment = message.attachments.new(account_id: message.account_id, file_type: :file) - attachment.file.attach(io: Rails.root.join('spec/assets/attachment.pdf').open, filename: 'attachment.pdf', - content_type: 'application/pdf') - - allow(telegram_attachment_response).to receive(:success?).and_return(true) - allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_456' }] }) - allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) - expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456') - end - - it 'send image failed' do - telegram_attachment_response = double - attachment = message.attachments.new(account_id: message.account_id, file_type: :image) - attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') - - allow(telegram_attachment_response).to receive(:success?).and_return(false) - allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'ok' => false, 'error_code' => '400', - 'description' => 'Bad Request: invalid file id' }) - allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) - telegram_channel.send_message_on_telegram(message) - expect(message.reload.status).to eq('failed') - expect(message.reload.external_error).to eq('400, Bad Request: invalid file id') - end - end - - context 'when a valid message and valid attachment' do - it 'send both message and attachment' do - message = create(:message, message_type: :outgoing, content: 'test', - conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) - - telegram_message_response = double - telegram_attachment_response = double - attachment = message.attachments.new(account_id: message.account_id, file_type: :image) - attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') - - allow(telegram_message_response).to receive(:success?).and_return(true) - allow(telegram_message_response).to receive(:parsed_response).and_return({ 'result' => { 'message_id' => 'telegram_456' } }) - allow(telegram_attachment_response).to receive(:success?).and_return(true) - allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_789' }] }) - - allow(telegram_channel).to receive(:message_request).and_return(telegram_message_response) - allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) - expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_789') - end end end diff --git a/spec/services/telegram/send_attachments_service_spec.rb b/spec/services/telegram/send_attachments_service_spec.rb new file mode 100644 index 000000000..2159acddd --- /dev/null +++ b/spec/services/telegram/send_attachments_service_spec.rb @@ -0,0 +1,114 @@ +require 'rails_helper' + +RSpec.describe Telegram::SendAttachmentsService do + describe '#perform' do + let(:channel) { create(:channel_telegram) } + let(:message) { build(:message, conversation: create(:conversation, inbox: channel.inbox)) } + let(:service) { described_class.new(message: message) } + let(:telegram_api_url) { channel.telegram_api_url } + + before do + allow(channel).to receive(:chat_id).and_return('chat123') + + stub_request(:post, "#{telegram_api_url}/sendMediaGroup") + .to_return(status: 200, body: { ok: true, result: [{ message_id: 'media' }] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + stub_request(:post, "#{telegram_api_url}/sendDocument") + .to_return(status: 200, body: { ok: true, result: { message_id: 'document' } }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'sends all types of attachments in seperate groups and returns the last successful message ID from the batch' do + attach_files(message) + result = service.perform + expect(result).to eq('document') + # videos and images are sent in a media group + # audio is sent in another group + expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.times(2) + expect(a_request(:post, "#{telegram_api_url}/sendDocument")).to have_been_made.once + end + + context 'when all attachments are documents' do + before do + 2.times { attach_file_to_message(message, 'file', 'sample.pdf', 'application/pdf') } + message.save! + end + + it 'sends documents individually and returns the message ID of the first successful document' do + result = service.perform + expect(result).to eq('document') + expect(a_request(:post, "#{telegram_api_url}/sendDocument")).to have_been_made.times(2) + end + end + + context 'when all attachments are photo and video' do + before do + 2.times { attach_file_to_message(message, 'image', 'sample.png', 'image/png') } + attach_file_to_message(message, 'video', 'sample.mp4', 'video/mp4') + message.save! + end + + it 'sends in a single media group and returns the message ID' do + result = service.perform + expect(result).to eq('media') + expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.once + end + end + + context 'when all attachments are audio' do + before do + 2.times { attach_file_to_message(message, 'audio', 'sample.mp3', 'audio/mpeg') } + message.save! + end + + it 'sends audio messages in single media group and returns the message ID' do + result = service.perform + expect(result).to eq('media') + expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.once + end + end + + context 'when all attachments are photos, videos, and audio' do + before do + attach_file_to_message(message, 'image', 'sample.png', 'image/png') + attach_file_to_message(message, 'video', 'sample.mp4', 'video/mp4') + attach_file_to_message(message, 'audio', 'sample.mp3', 'audio/mpeg') + message.save! + end + + it 'sends photos and videos in a media group and audio in a separate group' do + result = service.perform + expect(result).to eq('media') + expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.times(2) + end + end + + context 'when an attachment fails to send' do + before do + stub_request(:post, "#{telegram_api_url}/sendDocument") + .to_return(status: 500, body: { ok: false, + description: 'Internal server error' }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'logs an error, stops processing, and returns nil' do + attach_files(message) + expect(Rails.logger).to receive(:error).at_least(:once) + result = service.perform + expect(result).to be_nil + expect(a_request(:post, "#{telegram_api_url}/sendDocument")).to have_been_made.once + end + end + + def attach_files(message) + attach_file_to_message(message, 'file', 'sample.pdf', 'application/pdf') + attach_file_to_message(message, 'image', 'sample.png', 'image/png') + attach_file_to_message(message, 'video', 'sample.mp4', 'video/mp4') + attach_file_to_message(message, 'audio', 'sample.mp3', 'audio/mpeg') + message.save! + end + + def attach_file_to_message(message, type, filename, content_type) + attachment = message.attachments.new(account_id: message.account_id, file_type: type) + attachment.file.attach(io: Rails.root.join("spec/assets/#{filename}").open, filename: filename, content_type: content_type) + end + end +end