From b87b7972c1868150becdc356e4d535149191acf8 Mon Sep 17 00:00:00 2001 From: ruslan Date: Tue, 17 Jun 2025 06:35:23 +0300 Subject: [PATCH] feat(channel): add support for Telegram Business bots (#10181) (#11663) Added support for Telegram Business bots. Telegram webhooks from such bots include the business_message field, which we transform into a standard message for Chatwoot. This PR also modifies how we handle replies, attachments, and image uploads when working with Telegram Business bots. demo: https://drive.google.com/file/d/1Yz82wXBVRtb-mxjXogkUju4hlJbt3qyh/view?usp=sharing&t=4 Fixes #10181 --- app/jobs/webhooks/telegram_events_job.rb | 2 +- app/models/channel/telegram.rb | 19 ++++++- .../telegram/incoming_message_service.rb | 32 ++++++++++- app/services/telegram/param_helpers.rb | 37 +++++++++++-- .../telegram/send_attachments_service.rb | 12 ++++ .../telegram/update_message_service.rb | 5 ++ spec/models/channel/telegram_spec.rb | 18 ++++++ .../telegram/incoming_message_service_spec.rb | 55 +++++++++++++++++++ .../telegram/send_attachments_service_spec.rb | 16 ++++++ .../telegram/update_message_service_spec.rb | 18 ++++++ 10 files changed, 203 insertions(+), 11 deletions(-) diff --git a/app/jobs/webhooks/telegram_events_job.rb b/app/jobs/webhooks/telegram_events_job.rb index 4f16c401d..ae5488203 100644 --- a/app/jobs/webhooks/telegram_events_job.rb +++ b/app/jobs/webhooks/telegram_events_job.rb @@ -35,7 +35,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob def process_event_params(channel, params) return unless params[:telegram] - if params.dig(:telegram, :edited_message).present? + if params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present? Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform else Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index 9e4b95c57..b00897614 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -69,6 +69,10 @@ class Channel::Telegram < ApplicationRecord message.conversation[:additional_attributes]['chat_id'] end + def business_connection_id(message) + message.conversation[:additional_attributes]['business_connection_id'] + end + def reply_to_message_id(message) message.content_attributes['in_reply_to_external_id'] end @@ -95,7 +99,13 @@ class Channel::Telegram < ApplicationRecord end def send_message(message) - response = message_request(chat_id(message), message.outgoing_content, reply_markup(message), reply_to_message_id(message)) + response = message_request( + chat_id(message), + message.outgoing_content, + reply_markup(message), + reply_to_message_id(message), + business_connection_id: business_connection_id(message) + ) process_error(message, response) response.parsed_response['result']['message_id'] if response.success? end @@ -131,9 +141,12 @@ class Channel::Telegram < ApplicationRecord stripped_html.gsub('<br>', "\n") end - def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil) + def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil) text_payload = convert_markdown_to_telegram_html(text) + business_body = {} + business_body[:business_connection_id] = business_connection_id if business_connection_id + HTTParty.post("#{telegram_api_url}/sendMessage", body: { chat_id: chat_id, @@ -141,6 +154,6 @@ class Channel::Telegram < ApplicationRecord reply_markup: reply_markup, parse_mode: 'HTML', reply_to_message_id: reply_to_message_id - }) + }.merge(business_body)) end end diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index 3a186676e..6eeb192f2 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -8,17 +8,25 @@ class Telegram::IncomingMessageService def perform # chatwoot doesn't support group conversations at the moment + transform_business_message! return unless private_message? set_contact update_contact_avatar set_conversation + # TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request. + # Otherwise, the client will see their messages as unread. + # Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations. + # We have two options: + # 1. Send the read request to Telegram here, immediately when the message is created. + # 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message. + # See: https://core.telegram.org/bots/api#readbusinessmessage @message = @conversation.messages.build( content: telegram_params_message_content, account_id: @inbox.account_id, inbox_id: @inbox.id, - message_type: :incoming, - sender: @contact, + message_type: message_type, + sender: message_sender, content_attributes: telegram_params_content_attributes, source_id: telegram_params_message_id.to_s ) @@ -36,6 +44,11 @@ class Telegram::IncomingMessageService contact_attributes: contact_attributes ).perform + # TODO: Should we update contact_attributes when the user changes their first or last name? + # In business chats, when our Telegram bot initiates the conversation, + # the message does not include a language code. + # This is critical for AI assistants and translation plugins. + @contact_inbox = contact_inbox @contact = contact_inbox.contact end @@ -89,10 +102,19 @@ class Telegram::IncomingMessageService def conversation_additional_attributes { - chat_id: telegram_params_chat_id + chat_id: telegram_params_chat_id, + business_connection_id: telegram_params_business_connection_id } end + def message_type + business_message_outgoing? ? :outgoing : :incoming + end + + def message_sender + business_message_outgoing? ? nil : @contact + end + def file_content_type return :image if image_message? return :audio if audio_message? @@ -191,4 +213,8 @@ class Telegram::IncomingMessageService params[:message][:video].presence || params[:message][:video_note].presence end + + def transform_business_message! + params[:message] = params[:business_message] if params[:business_message] && !params[:message] + end end diff --git a/app/services/telegram/param_helpers.rb b/app/services/telegram/param_helpers.rb index 6201fa0f3..7bbec9264 100644 --- a/app/services/telegram/param_helpers.rb +++ b/app/services/telegram/param_helpers.rb @@ -13,6 +13,17 @@ module Telegram::ParamHelpers {} end + def business_message? + telegram_params_business_connection_id.present? + end + + # In business bot mode we will receive messages from our telegram. + # This is our messages posted via telegram client. + # Such messages should be outgoing (from us to client) + def business_message_outgoing? + business_message? && telegram_params_base_object[:chat][:id] != telegram_params_base_object[:from][:id] + end + def message_params? params[:message].present? end @@ -29,24 +40,34 @@ module Telegram::ParamHelpers end end + def contact_params + if business_message_outgoing? + telegram_params_base_object[:chat] + else + telegram_params_base_object[:from] + end + end + def telegram_params_from_id + return telegram_params_base_object[:chat][:id] if business_message? + telegram_params_base_object[:from][:id] end def telegram_params_first_name - telegram_params_base_object[:from][:first_name] + contact_params[:first_name] end def telegram_params_last_name - telegram_params_base_object[:from][:last_name] + contact_params[:last_name] end def telegram_params_username - telegram_params_base_object[:from][:username] + contact_params[:username] end def telegram_params_language_code - telegram_params_base_object[:from][:language_code] + contact_params[:language_code] end def telegram_params_chat_id @@ -57,6 +78,14 @@ module Telegram::ParamHelpers end end + def telegram_params_business_connection_id + if callback_query_params? + params[:callback_query][:message][:business_connection_id] + else + telegram_params_base_object[:business_connection_id] + end + end + def telegram_params_message_content if callback_query_params? params[:callback_query][:data] diff --git a/app/services/telegram/send_attachments_service.rb b/app/services/telegram/send_attachments_service.rb index 2f69026b2..e214fe78f 100644 --- a/app/services/telegram/send_attachments_service.rb +++ b/app/services/telegram/send_attachments_service.rb @@ -71,6 +71,7 @@ class Telegram::SendAttachmentsService HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup", body: { chat_id: chat_id, + **business_connection_body, media: attachments.map { |hash| hash.except(:attachment) }.to_json, reply_to_message_id: reply_to_message_id }) @@ -108,6 +109,7 @@ class Telegram::SendAttachmentsService HTTParty.post("#{channel.telegram_api_url}/sendDocument", body: { chat_id: chat_id, + **business_connection_body, document: file, reply_to_message_id: reply_to_message_id }, @@ -135,4 +137,14 @@ class Telegram::SendAttachmentsService def channel @channel ||= message.inbox.channel end + + def business_connection_id + @business_connection_id ||= channel.business_connection_id(message) + end + + def business_connection_body + body = {} + body[:business_connection_id] = business_connection_id if business_connection_id + body + end end diff --git a/app/services/telegram/update_message_service.rb b/app/services/telegram/update_message_service.rb index d76be5a3f..109d73ff8 100644 --- a/app/services/telegram/update_message_service.rb +++ b/app/services/telegram/update_message_service.rb @@ -5,6 +5,7 @@ class Telegram::UpdateMessageService pattr_initialize [:inbox!, :params!] def perform + transform_business_message! find_contact_inbox find_conversation find_message @@ -36,4 +37,8 @@ class Telegram::UpdateMessageService @message.update!(content: edited_message[:caption]) end end + + def transform_business_message! + params[:edited_message] = params[:edited_business_message] if params[:edited_business_message].present? + end end diff --git a/spec/models/channel/telegram_spec.rb b/spec/models/channel/telegram_spec.rb index 17aa848f6..dc401a165 100644 --- a/spec/models/channel/telegram_spec.rb +++ b/spec/models/channel/telegram_spec.rb @@ -114,6 +114,24 @@ RSpec.describe Channel::Telegram do expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') end + it 'sends message with business_connection_id' do + additional_attributes = { 'chat_id' => '123', 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' } + message = create(:message, message_type: :outgoing, content: 'test', + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: additional_attributes)) + + stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage") + .with( + body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id=&business_connection_id=eooW3KF5WB5HxTD7T826' + ) + .to_return( + status: 200, + body: { result: { message_id: 'telegram_123' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') + end + it 'send text message failed' do message = create(:message, message_type: :outgoing, content: 'test', conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 78a8e9d7c..ef43dbe24 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -89,6 +89,61 @@ describe Telegram::IncomingMessageService do end end + context 'when business connection messages' do + subject do + described_class.new(inbox: telegram_channel.inbox, params: params).perform + end + + let(:business_message_params) { message_params.merge('business_connection_id' => 'eooW3KF5WB5HxTD7T826') } + let(:params) do + { + 'update_id' => 2_342_342_343_242, + 'business_message' => { 'text' => 'test' }.deep_merge(business_message_params) + }.with_indifferent_access + end + + it 'creates appropriate conversations, message and contacts' do + subject + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, + 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) + contact = Contact.all.first + expect(contact.name).to eq('Sojan Jose') + expect(contact.additional_attributes['language_code']).to eq('en') + message = telegram_channel.inbox.messages.first + expect(message.content).to eq('test') + expect(message.message_type).to eq('incoming') + expect(message.sender).to eq(contact) + end + + context 'when sender is your business account' do + let(:business_message_params) do + message_params.merge( + 'business_connection_id' => 'eooW3KF5WB5HxTD7T826', + 'from' => { + 'id' => 42, 'is_bot' => false, 'first_name' => 'John', 'last_name' => 'Doe', 'username' => 'johndoe', 'language_code' => 'en' + } + ) + end + + it 'creates appropriate conversations, message and contacts' do + subject + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, + 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) + contact = Contact.all.first + expect(contact.name).to eq('Sojan Jose') + # TODO: The language code is not present when we send the first message to the client. + # Should we update it when the user replies? + expect(contact.additional_attributes['language_code']).to be_nil + message = telegram_channel.inbox.messages.first + expect(message.content).to eq('test') + expect(message.message_type).to eq('outgoing') + expect(message.sender).to be_nil + end + end + end + context 'when valid audio messages params' do it 'creates appropriate conversations, message and contacts' do allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mp3') diff --git a/spec/services/telegram/send_attachments_service_spec.rb b/spec/services/telegram/send_attachments_service_spec.rb index 2159acddd..f9c625780 100644 --- a/spec/services/telegram/send_attachments_service_spec.rb +++ b/spec/services/telegram/send_attachments_service_spec.rb @@ -40,6 +40,22 @@ RSpec.describe Telegram::SendAttachmentsService do end end + context 'when this is business chat' do + before { allow(channel).to receive(:business_connection_id).and_return('eooW3KF5WB5HxTD7T826') } + + it 'sends all types of attachments in seperate groups and returns the last successful message ID from the batch' do + attach_files(message) + service.perform + expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup") + .with { |req| req.body =~ /business_connection_id.+eooW3KF5WB5HxTD7T826/m }) + .to have_been_made.times(2) + + expect(a_request(:post, "#{telegram_api_url}/sendDocument") + .with { |req| req.body =~ /business_connection_id.+eooW3KF5WB5HxTD7T826/m }) + .to have_been_made.once + 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') } diff --git a/spec/services/telegram/update_message_service_spec.rb b/spec/services/telegram/update_message_service_spec.rb index 5c00c9009..33a279796 100644 --- a/spec/services/telegram/update_message_service_spec.rb +++ b/spec/services/telegram/update_message_service_spec.rb @@ -53,6 +53,24 @@ describe Telegram::UpdateMessageService do described_class.new(inbox: telegram_channel.inbox, params: caption_update_params.with_indifferent_access).perform expect(message.reload.content).to eq('updated caption') end + + context 'when business message' do + let(:text_update_params) do + { + 'update_id': 1, + 'edited_business_message': common_message_params.merge( + 'message_id': 48, + 'text': 'updated message' + ) + } + end + + it 'updates the message text when text is present' do + message = create(:message, conversation: conversation, source_id: text_update_params[:edited_business_message][:message_id]) + described_class.new(inbox: telegram_channel.inbox, params: text_update_params.with_indifferent_access).perform + expect(message.reload.content).to eq('updated message') + end + end end context 'when invalid update message params' do