feat: Support document file uploads on telegram channel (#9266)

This commit is contained in:
Sojan Jose
2024-05-02 14:05:14 -07:00
committed by GitHub
parent 3488a315d0
commit ac93af6028
4 changed files with 277 additions and 108 deletions

View File

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

View File

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

View File

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

View File

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