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
This commit is contained in:
ruslan
2025-06-17 06:35:23 +03:00
committed by GitHub
parent 149dab239a
commit b87b7972c1
10 changed files with 203 additions and 11 deletions

View File

@@ -35,7 +35,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob
def process_event_params(channel, params) def process_event_params(channel, params)
return unless params[:telegram] 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 Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
else else
Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform

View File

@@ -69,6 +69,10 @@ class Channel::Telegram < ApplicationRecord
message.conversation[:additional_attributes]['chat_id'] message.conversation[:additional_attributes]['chat_id']
end end
def business_connection_id(message)
message.conversation[:additional_attributes]['business_connection_id']
end
def reply_to_message_id(message) def reply_to_message_id(message)
message.content_attributes['in_reply_to_external_id'] message.content_attributes['in_reply_to_external_id']
end end
@@ -95,7 +99,13 @@ class Channel::Telegram < ApplicationRecord
end end
def send_message(message) 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) process_error(message, response)
response.parsed_response['result']['message_id'] if response.success? response.parsed_response['result']['message_id'] if response.success?
end end
@@ -131,9 +141,12 @@ class Channel::Telegram < ApplicationRecord
stripped_html.gsub('&lt;br&gt;', "\n") stripped_html.gsub('&lt;br&gt;', "\n")
end 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) 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", HTTParty.post("#{telegram_api_url}/sendMessage",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
@@ -141,6 +154,6 @@ class Channel::Telegram < ApplicationRecord
reply_markup: reply_markup, reply_markup: reply_markup,
parse_mode: 'HTML', parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}) }.merge(business_body))
end end
end end

View File

@@ -8,17 +8,25 @@ class Telegram::IncomingMessageService
def perform def perform
# chatwoot doesn't support group conversations at the moment # chatwoot doesn't support group conversations at the moment
transform_business_message!
return unless private_message? return unless private_message?
set_contact set_contact
update_contact_avatar update_contact_avatar
set_conversation 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( @message = @conversation.messages.build(
content: telegram_params_message_content, content: telegram_params_message_content,
account_id: @inbox.account_id, account_id: @inbox.account_id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
message_type: :incoming, message_type: message_type,
sender: @contact, sender: message_sender,
content_attributes: telegram_params_content_attributes, content_attributes: telegram_params_content_attributes,
source_id: telegram_params_message_id.to_s source_id: telegram_params_message_id.to_s
) )
@@ -36,6 +44,11 @@ class Telegram::IncomingMessageService
contact_attributes: contact_attributes contact_attributes: contact_attributes
).perform ).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_inbox = contact_inbox
@contact = contact_inbox.contact @contact = contact_inbox.contact
end end
@@ -89,10 +102,19 @@ class Telegram::IncomingMessageService
def conversation_additional_attributes 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 end
def message_type
business_message_outgoing? ? :outgoing : :incoming
end
def message_sender
business_message_outgoing? ? nil : @contact
end
def file_content_type def file_content_type
return :image if image_message? return :image if image_message?
return :audio if audio_message? return :audio if audio_message?
@@ -191,4 +213,8 @@ class Telegram::IncomingMessageService
params[:message][:video].presence || params[:message][:video].presence ||
params[:message][:video_note].presence params[:message][:video_note].presence
end end
def transform_business_message!
params[:message] = params[:business_message] if params[:business_message] && !params[:message]
end
end end

View File

@@ -13,6 +13,17 @@ module Telegram::ParamHelpers
{} {}
end 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? def message_params?
params[:message].present? params[:message].present?
end end
@@ -29,24 +40,34 @@ module Telegram::ParamHelpers
end end
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 def telegram_params_from_id
return telegram_params_base_object[:chat][:id] if business_message?
telegram_params_base_object[:from][:id] telegram_params_base_object[:from][:id]
end end
def telegram_params_first_name def telegram_params_first_name
telegram_params_base_object[:from][:first_name] contact_params[:first_name]
end end
def telegram_params_last_name def telegram_params_last_name
telegram_params_base_object[:from][:last_name] contact_params[:last_name]
end end
def telegram_params_username def telegram_params_username
telegram_params_base_object[:from][:username] contact_params[:username]
end end
def telegram_params_language_code def telegram_params_language_code
telegram_params_base_object[:from][:language_code] contact_params[:language_code]
end end
def telegram_params_chat_id def telegram_params_chat_id
@@ -57,6 +78,14 @@ module Telegram::ParamHelpers
end end
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 def telegram_params_message_content
if callback_query_params? if callback_query_params?
params[:callback_query][:data] params[:callback_query][:data]

View File

@@ -71,6 +71,7 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup", HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
**business_connection_body,
media: attachments.map { |hash| hash.except(:attachment) }.to_json, media: attachments.map { |hash| hash.except(:attachment) }.to_json,
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}) })
@@ -108,6 +109,7 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendDocument", HTTParty.post("#{channel.telegram_api_url}/sendDocument",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
**business_connection_body,
document: file, document: file,
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}, },
@@ -135,4 +137,14 @@ class Telegram::SendAttachmentsService
def channel def channel
@channel ||= message.inbox.channel @channel ||= message.inbox.channel
end 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 end

View File

@@ -5,6 +5,7 @@ class Telegram::UpdateMessageService
pattr_initialize [:inbox!, :params!] pattr_initialize [:inbox!, :params!]
def perform def perform
transform_business_message!
find_contact_inbox find_contact_inbox
find_conversation find_conversation
find_message find_message
@@ -36,4 +37,8 @@ class Telegram::UpdateMessageService
@message.update!(content: edited_message[:caption]) @message.update!(content: edited_message[:caption])
end end
end end
def transform_business_message!
params[:edited_message] = params[:edited_business_message] if params[:edited_business_message].present?
end
end end

View File

@@ -114,6 +114,24 @@ RSpec.describe Channel::Telegram do
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end 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 it 'send text message failed' do
message = create(:message, message_type: :outgoing, content: 'test', message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))

View File

@@ -89,6 +89,61 @@ describe Telegram::IncomingMessageService do
end end
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 context 'when valid audio messages params' do
it 'creates appropriate conversations, message and contacts' 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') allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mp3')

View File

@@ -40,6 +40,22 @@ RSpec.describe Telegram::SendAttachmentsService do
end end
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 context 'when all attachments are photo and video' do
before do before do
2.times { attach_file_to_message(message, 'image', 'sample.png', 'image/png') } 2.times { attach_file_to_message(message, 'image', 'sample.png', 'image/png') }

View File

@@ -53,6 +53,24 @@ describe Telegram::UpdateMessageService do
described_class.new(inbox: telegram_channel.inbox, params: caption_update_params.with_indifferent_access).perform described_class.new(inbox: telegram_channel.inbox, params: caption_update_params.with_indifferent_access).perform
expect(message.reload.content).to eq('updated caption') expect(message.reload.content).to eq('updated caption')
end 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 end
context 'when invalid update message params' do context 'when invalid update message params' do