Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
63
research/chatwoot/lib/integrations/bot_processor_service.rb
Normal file
63
research/chatwoot/lib/integrations/bot_processor_service.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
class Integrations::BotProcessorService
|
||||
pattr_initialize [:event_name!, :hook!, :event_data!]
|
||||
|
||||
def perform
|
||||
message = event_data[:message]
|
||||
return unless should_run_processor?(message)
|
||||
|
||||
process_content(message)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_run_processor?(message)
|
||||
return if message.private?
|
||||
return unless processable_message?(message)
|
||||
return unless conversation.pending?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def conversation
|
||||
message = event_data[:message]
|
||||
@conversation ||= message.conversation
|
||||
end
|
||||
|
||||
def process_content(message)
|
||||
content = message_content(message)
|
||||
response = get_response(conversation.contact_inbox.source_id, content) if content.present?
|
||||
process_response(message, response) if response.present?
|
||||
end
|
||||
|
||||
def message_content(message)
|
||||
# TODO: might needs to change this to a way that we fetch the updated value from event data instead
|
||||
# cause the message.updated event could be that that the message was deleted
|
||||
|
||||
return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated'
|
||||
|
||||
message.content
|
||||
end
|
||||
|
||||
def processable_message?(message)
|
||||
# TODO: change from reportable and create a dedicated method for this?
|
||||
return unless message.reportable?
|
||||
return if message.outgoing? && !processable_outgoing_message?(message)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def processable_outgoing_message?(message)
|
||||
event_name == 'message.updated' && ['input_select'].include?(message.content_type)
|
||||
end
|
||||
|
||||
def process_action(message, action)
|
||||
case action
|
||||
when 'handoff'
|
||||
message.conversation.bot_handoff!
|
||||
when 'resolve'
|
||||
message.conversation.resolved!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
class Integrations::Captain::ProcessorService < Integrations::BotProcessorService
|
||||
pattr_initialize [:event_name!, :hook!, :event_data!]
|
||||
|
||||
private
|
||||
|
||||
def get_response(_session_id, message_content)
|
||||
call_captain(message_content)
|
||||
end
|
||||
|
||||
def process_response(message, response)
|
||||
if response == 'conversation_handoff'
|
||||
message.conversation.bot_handoff!
|
||||
else
|
||||
create_conversation(message, { content: response })
|
||||
end
|
||||
end
|
||||
|
||||
def create_conversation(message, content_params)
|
||||
return if content_params.blank?
|
||||
|
||||
conversation = message.conversation
|
||||
conversation.messages.create!(
|
||||
content_params.merge(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def call_captain(message_content)
|
||||
url = "#{GlobalConfigService.load('CAPTAIN_API_URL',
|
||||
'')}/accounts/#{hook.settings['account_id']}/assistants/#{hook.settings['assistant_id']}/chat"
|
||||
|
||||
headers = {
|
||||
'X-USER-EMAIL' => hook.settings['account_email'],
|
||||
'X-USER-TOKEN' => hook.settings['access_token'],
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
|
||||
body = {
|
||||
message: message_content,
|
||||
previous_messages: previous_messages
|
||||
}
|
||||
|
||||
response = HTTParty.post(url, headers: headers, body: body.to_json)
|
||||
response.parsed_response['message']
|
||||
end
|
||||
|
||||
def previous_messages
|
||||
previous_messages = []
|
||||
conversation.messages.where(message_type: [:outgoing, :incoming]).where(private: false).offset(1).find_each do |message|
|
||||
next if message.content_type != 'text'
|
||||
|
||||
role = determine_role(message)
|
||||
previous_messages << { message: message.content, type: role }
|
||||
end
|
||||
previous_messages
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'User' : 'Bot'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
require 'google/cloud/dialogflow/v2'
|
||||
|
||||
class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorService
|
||||
pattr_initialize [:event_name!, :hook!, :event_data!]
|
||||
|
||||
private
|
||||
|
||||
def message_content(message)
|
||||
# TODO: might needs to change this to a way that we fetch the updated value from event data instead
|
||||
# cause the message.updated event could be that that the message was deleted
|
||||
|
||||
return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated'
|
||||
|
||||
message.content
|
||||
end
|
||||
|
||||
def get_response(session_id, message_content)
|
||||
if hook.settings['credentials'].blank?
|
||||
Rails.logger.warn "Account: #{hook.try(:account_id)} Hook: #{hook.id} credentials are not present." && return
|
||||
end
|
||||
|
||||
configure_dialogflow_client_defaults
|
||||
detect_intent(session_id, message_content)
|
||||
rescue Google::Cloud::PermissionDeniedError => e
|
||||
Rails.logger.warn "DialogFlow Error: (account-#{hook.try(:account_id)}, hook-#{hook.id}) #{e.message}"
|
||||
hook.prompt_reauthorization!
|
||||
hook.disable
|
||||
end
|
||||
|
||||
def process_response(message, response)
|
||||
fulfillment_messages = response.query_result['fulfillment_messages']
|
||||
fulfillment_messages.each do |fulfillment_message|
|
||||
content_params = generate_content_params(fulfillment_message)
|
||||
if content_params['action'].present?
|
||||
process_action(message, content_params['action'])
|
||||
else
|
||||
create_conversation(message, content_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_content_params(fulfillment_message)
|
||||
text_response = fulfillment_message['text'].to_h
|
||||
content_params = { content: text_response[:text].first } if text_response[:text].present?
|
||||
content_params ||= fulfillment_message['payload'].to_h
|
||||
content_params
|
||||
end
|
||||
|
||||
def create_conversation(message, content_params)
|
||||
return if content_params.blank?
|
||||
|
||||
conversation = message.conversation
|
||||
conversation.messages.create!(
|
||||
content_params.merge(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def configure_dialogflow_client_defaults
|
||||
::Google::Cloud::Dialogflow::V2::Sessions::Client.configure do |config|
|
||||
config.timeout = 10.0
|
||||
config.credentials = hook.settings['credentials']
|
||||
config.endpoint = dialogflow_endpoint
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_region
|
||||
region = hook.settings['region'].to_s.strip
|
||||
(region.presence || 'global')
|
||||
end
|
||||
|
||||
def dialogflow_endpoint
|
||||
region = normalized_region
|
||||
return 'dialogflow.googleapis.com' if region == 'global'
|
||||
|
||||
"#{region}-dialogflow.googleapis.com"
|
||||
end
|
||||
|
||||
def detect_intent(session_id, message)
|
||||
client = ::Google::Cloud::Dialogflow::V2::Sessions::Client.new
|
||||
session = build_session_path(session_id)
|
||||
query_input = { text: { text: message, language_code: 'en-US' } }
|
||||
client.detect_intent session: session, query_input: query_input
|
||||
end
|
||||
|
||||
def build_session_path(session_id)
|
||||
project_id = hook.settings['project_id']
|
||||
region = normalized_region
|
||||
|
||||
if region == 'global'
|
||||
"projects/#{project_id}/agent/sessions/#{session_id}"
|
||||
else
|
||||
"projects/#{project_id}/locations/#{region}/agent/sessions/#{session_id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
54
research/chatwoot/lib/integrations/dyte/processor_service.rb
Normal file
54
research/chatwoot/lib/integrations/dyte/processor_service.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class Integrations::Dyte::ProcessorService
|
||||
pattr_initialize [:account!, :conversation!]
|
||||
|
||||
def create_a_meeting(agent)
|
||||
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
|
||||
response = dyte_client.create_a_meeting(title)
|
||||
|
||||
return response if response[:error].present?
|
||||
|
||||
meeting = response
|
||||
message = create_a_dyte_integration_message(meeting, title, agent)
|
||||
message.push_event_data
|
||||
end
|
||||
|
||||
def add_participant_to_meeting(meeting_id, user)
|
||||
dyte_client.add_participant_to_meeting(meeting_id, user.id, user.name, avatar_url(user))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_a_dyte_integration_message(meeting, title, agent)
|
||||
@conversation.messages.create!(
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :outgoing,
|
||||
content_type: :integrations,
|
||||
content: title,
|
||||
content_attributes: {
|
||||
type: 'dyte',
|
||||
data: {
|
||||
meeting_id: meeting['id']
|
||||
}
|
||||
},
|
||||
sender: agent
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def avatar_url(user)
|
||||
return user.avatar_url if user.avatar_url.present?
|
||||
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/integrations/slack/user.png"
|
||||
end
|
||||
|
||||
def dyte_hook
|
||||
@dyte_hook ||= account.hooks.find_by!(app_id: 'dyte')
|
||||
end
|
||||
|
||||
def dyte_client
|
||||
credentials = dyte_hook.settings
|
||||
@dyte_client ||= Dyte.new(credentials['organization_id'], credentials['api_key'])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Integrations::Facebook::DeliveryStatus
|
||||
pattr_initialize [:params!]
|
||||
|
||||
def perform
|
||||
return if facebook_channel.blank?
|
||||
return unless conversation
|
||||
|
||||
process_delivery_status if params.delivery_watermark
|
||||
process_read_status if params.read_watermark
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_delivery_status
|
||||
timestamp = Time.zone.at(params.delivery_watermark.to_i).to_datetime.utc
|
||||
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :delivered)
|
||||
end
|
||||
|
||||
def process_read_status
|
||||
timestamp = Time.zone.at(params.read_watermark.to_i).to_datetime.utc
|
||||
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :read)
|
||||
end
|
||||
|
||||
def contact
|
||||
::ContactInbox.find_by(source_id: params.sender_id)&.contact
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= ::Conversation.find_by(contact_id: contact.id) if contact.present?
|
||||
end
|
||||
|
||||
def facebook_channel
|
||||
@facebook_channel ||= Channel::FacebookPage.find_by(page_id: params.recipient_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Integrations::Facebook::MessageCreator
|
||||
attr_reader :response
|
||||
|
||||
def initialize(response)
|
||||
@response = response
|
||||
end
|
||||
|
||||
def perform
|
||||
# begin
|
||||
if agent_message_via_echo?
|
||||
create_agent_message
|
||||
else
|
||||
create_contact_message
|
||||
end
|
||||
# rescue => e
|
||||
# ChatwootExceptionTracker.new(e).capture_exception
|
||||
# end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_message_via_echo?
|
||||
# TODO : check and remove send_from_chatwoot_app if not working
|
||||
response.echo? && !response.sent_from_chatwoot_app?
|
||||
# this means that it is an agent message from page, but not sent from chatwoot.
|
||||
# User can send from fb page directly on mobile / web messenger, so this case should be handled as agent message
|
||||
end
|
||||
|
||||
def create_agent_message
|
||||
Channel::FacebookPage.where(page_id: response.sender_id).each do |page|
|
||||
mb = Messages::Facebook::MessageBuilder.new(response, page.inbox, outgoing_echo: true)
|
||||
mb.perform
|
||||
end
|
||||
end
|
||||
|
||||
def create_contact_message
|
||||
Channel::FacebookPage.where(page_id: response.recipient_id).each do |page|
|
||||
mb = Messages::Facebook::MessageBuilder.new(response, page.inbox)
|
||||
mb.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Integrations::Facebook::MessageParser
|
||||
def initialize(response_json)
|
||||
@response = JSON.parse(response_json)
|
||||
@messaging = @response['messaging'] || @response['standby']
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@messaging.dig('sender', 'id')
|
||||
end
|
||||
|
||||
def recipient_id
|
||||
@messaging.dig('recipient', 'id')
|
||||
end
|
||||
|
||||
def time_stamp
|
||||
@messaging['timestamp']
|
||||
end
|
||||
|
||||
def content
|
||||
@messaging.dig('message', 'text')
|
||||
end
|
||||
|
||||
def sequence
|
||||
@messaging.dig('message', 'seq')
|
||||
end
|
||||
|
||||
def attachments
|
||||
@messaging.dig('message', 'attachments')
|
||||
end
|
||||
|
||||
def identifier
|
||||
@messaging.dig('message', 'mid')
|
||||
end
|
||||
|
||||
def delivery
|
||||
@messaging['delivery']
|
||||
end
|
||||
|
||||
def read
|
||||
@messaging['read']
|
||||
end
|
||||
|
||||
def read_watermark
|
||||
read&.dig('watermark')
|
||||
end
|
||||
|
||||
def delivery_watermark
|
||||
delivery&.dig('watermark')
|
||||
end
|
||||
|
||||
def echo?
|
||||
@messaging.dig('message', 'is_echo')
|
||||
end
|
||||
|
||||
# TODO : i don't think the payload contains app_id. if not remove
|
||||
def app_id
|
||||
@messaging.dig('message', 'app_id')
|
||||
end
|
||||
|
||||
# TODO : does this work ?
|
||||
def sent_from_chatwoot_app?
|
||||
app_id && app_id == GlobalConfigService.load('FB_APP_ID', '').to_i
|
||||
end
|
||||
|
||||
def in_reply_to_external_id
|
||||
@messaging.dig('message', 'reply_to', 'mid')
|
||||
end
|
||||
end
|
||||
|
||||
# Sample Response
|
||||
# {
|
||||
# "sender":{
|
||||
# "id":"USER_ID"
|
||||
# },
|
||||
# "recipient":{
|
||||
# "id":"PAGE_ID"
|
||||
# },
|
||||
# "timestamp":1458692752478,
|
||||
# "message":{
|
||||
# "mid":"mid.1457764197618:41d102a3e1ae206a38",
|
||||
# "seq":73,
|
||||
# "text":"hello, world!",
|
||||
# "quick_reply": {
|
||||
# "payload": "DEVELOPER_DEFINED_PAYLOAD"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'google/cloud/translate/v3'
|
||||
class Integrations::GoogleTranslate::DetectLanguageService
|
||||
pattr_initialize [:hook!, :message!]
|
||||
|
||||
def perform
|
||||
return unless valid_message?
|
||||
return if conversation.additional_attributes['conversation_language'].present?
|
||||
|
||||
text = message.content[0...1500]
|
||||
response = client.detect_language(
|
||||
content: text,
|
||||
parent: "projects/#{hook.settings['project_id']}"
|
||||
)
|
||||
|
||||
update_conversation(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_message?
|
||||
message.incoming? && message.content.present?
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= message.conversation
|
||||
end
|
||||
|
||||
def update_conversation(response)
|
||||
return if response&.languages.blank?
|
||||
|
||||
conversation_language = response.languages.first.language_code
|
||||
additional_attributes = conversation.additional_attributes.merge({ conversation_language: conversation_language })
|
||||
conversation.update!(additional_attributes: additional_attributes)
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= ::Google::Cloud::Translate::V3::TranslationService::Client.new do |config|
|
||||
config.credentials = hook.settings['credentials']
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
require 'google/cloud/translate/v3'
|
||||
|
||||
class Integrations::GoogleTranslate::ProcessorService
|
||||
pattr_initialize [:message!, :target_language!]
|
||||
|
||||
def perform
|
||||
return if hook.blank?
|
||||
|
||||
content = translation_content
|
||||
return if content.blank?
|
||||
|
||||
response = client.translate_text(
|
||||
contents: [content],
|
||||
target_language_code: bcp47_language_code,
|
||||
parent: "projects/#{hook.settings['project_id']}",
|
||||
mime_type: mime_type
|
||||
)
|
||||
|
||||
return if response.translations.first.blank?
|
||||
|
||||
response.translations.first.translated_text
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bcp47_language_code
|
||||
target_language.tr('_', '-')
|
||||
end
|
||||
|
||||
def email_channel?
|
||||
message&.inbox&.email?
|
||||
end
|
||||
|
||||
def email_content
|
||||
@email_content ||= {
|
||||
html: message.content_attributes.dig('email', 'html_content', 'full'),
|
||||
text: message.content_attributes.dig('email', 'text_content', 'full'),
|
||||
content_type: message.content_attributes.dig('email', 'content_type')
|
||||
}
|
||||
end
|
||||
|
||||
def html_content_available?
|
||||
email_content[:html].present?
|
||||
end
|
||||
|
||||
def plain_text_content_available?
|
||||
email_content[:content_type]&.include?('text/plain') &&
|
||||
email_content[:text].present?
|
||||
end
|
||||
|
||||
def translation_content
|
||||
return message.content unless email_channel?
|
||||
return email_content[:html] if html_content_available?
|
||||
return email_content[:text] if plain_text_content_available?
|
||||
|
||||
message.content
|
||||
end
|
||||
|
||||
def mime_type
|
||||
if email_channel? && html_content_available?
|
||||
'text/html'
|
||||
else
|
||||
'text/plain'
|
||||
end
|
||||
end
|
||||
|
||||
def hook
|
||||
@hook ||= message.account.hooks.find_by(app_id: 'google_translate')
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= ::Google::Cloud::Translate::V3::TranslationService::Client.new do |config|
|
||||
config.credentials = hook.settings['credentials']
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
class Integrations::Linear::ProcessorService
|
||||
pattr_initialize [:account!]
|
||||
|
||||
def teams
|
||||
response = linear_client.teams
|
||||
return { error: response[:error] } if response[:error]
|
||||
|
||||
{ data: response['teams']['nodes'].map(&:as_json) }
|
||||
end
|
||||
|
||||
def team_entities(team_id)
|
||||
response = linear_client.team_entities(team_id)
|
||||
return response if response[:error]
|
||||
|
||||
{
|
||||
data: {
|
||||
users: response['users']['nodes'].map(&:as_json),
|
||||
projects: response['projects']['nodes'].map(&:as_json),
|
||||
states: response['workflowStates']['nodes'].map(&:as_json),
|
||||
labels: response['issueLabels']['nodes'].map(&:as_json)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def create_issue(params, user = nil)
|
||||
response = linear_client.create_issue(params, user)
|
||||
return response if response[:error]
|
||||
|
||||
{
|
||||
data: { id: response['issueCreate']['issue']['id'],
|
||||
title: response['issueCreate']['issue']['title'],
|
||||
identifier: response['issueCreate']['issue']['identifier'] }
|
||||
}
|
||||
end
|
||||
|
||||
def link_issue(link, issue_id, title, user = nil)
|
||||
response = linear_client.link_issue(link, issue_id, title, user)
|
||||
return response if response[:error]
|
||||
|
||||
{
|
||||
data: {
|
||||
id: issue_id,
|
||||
link: link,
|
||||
link_id: response.with_indifferent_access[:attachmentLinkURL][:attachment][:id]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def unlink_issue(link_id)
|
||||
response = linear_client.unlink_issue(link_id)
|
||||
return response if response[:error]
|
||||
|
||||
{
|
||||
data: { link_id: link_id }
|
||||
}
|
||||
end
|
||||
|
||||
def search_issue(term)
|
||||
response = linear_client.search_issue(term)
|
||||
|
||||
return response if response[:error]
|
||||
|
||||
{ data: response['searchIssues']['nodes'].map(&:as_json) }
|
||||
end
|
||||
|
||||
def linked_issues(url)
|
||||
response = linear_client.linked_issues(url)
|
||||
return response if response[:error]
|
||||
|
||||
{ data: response['attachmentsForURL']['nodes'].map(&:as_json) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def linear_hook
|
||||
@linear_hook ||= account.hooks.find_by!(app_id: 'linear')
|
||||
end
|
||||
|
||||
def linear_client
|
||||
@linear_client ||= Linear.new(linear_hook.access_token)
|
||||
end
|
||||
end
|
||||
170
research/chatwoot/lib/integrations/llm_base_service.rb
Normal file
170
research/chatwoot/lib/integrations/llm_base_service.rb
Normal file
@@ -0,0 +1,170 @@
|
||||
class Integrations::LlmBaseService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
# sticking with 120000 to be safe
|
||||
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
|
||||
TOKEN_LIMIT = 400_000
|
||||
GPT_MODEL = Llm::Config::DEFAULT_MODEL
|
||||
ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion fix_spelling_grammar casual professional friendly confident
|
||||
straightforward improve].freeze
|
||||
CACHEABLE_EVENTS = %w[].freeze
|
||||
|
||||
pattr_initialize [:hook!, :event!]
|
||||
|
||||
def perform
|
||||
return nil unless valid_event_name?
|
||||
|
||||
return value_from_cache if value_from_cache.present?
|
||||
|
||||
response = send("#{event_name}_message")
|
||||
save_to_cache(response) if response.present?
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
event['name']
|
||||
end
|
||||
|
||||
def cache_key
|
||||
return nil unless event_is_cacheable?
|
||||
|
||||
return nil unless conversation
|
||||
|
||||
# since the value from cache depends on the conversation last_activity_at, it will always be fresh
|
||||
format(::Redis::Alfred::OPENAI_CONVERSATION_KEY, event_name: event_name, conversation_id: conversation.id,
|
||||
updated_at: conversation.last_activity_at.to_i)
|
||||
end
|
||||
|
||||
def value_from_cache
|
||||
return nil unless event_is_cacheable?
|
||||
return nil if cache_key.blank?
|
||||
|
||||
deserialize_cached_value(Redis::Alfred.get(cache_key))
|
||||
end
|
||||
|
||||
def deserialize_cached_value(value)
|
||||
return nil if value.blank?
|
||||
|
||||
JSON.parse(value, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
# If json parse failed, returning the value as is will fail too
|
||||
# since we access the keys as symbols down the line
|
||||
# So it's best to return nil
|
||||
nil
|
||||
end
|
||||
|
||||
def save_to_cache(response)
|
||||
return nil unless event_is_cacheable?
|
||||
|
||||
# Serialize to JSON
|
||||
# This makes parsing easy when response is a hash
|
||||
Redis::Alfred.setex(cache_key, response.to_json)
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= hook.account.conversations.find_by(display_id: event['data']['conversation_display_id'])
|
||||
end
|
||||
|
||||
def valid_event_name?
|
||||
# self.class::ALLOWED_EVENT_NAMES is way to access ALLOWED_EVENT_NAMES defined in the class hierarchy of the current object.
|
||||
# This ensures that if ALLOWED_EVENT_NAMES is updated elsewhere in it's ancestors, we access the latest value.
|
||||
self.class::ALLOWED_EVENT_NAMES.include?(event_name)
|
||||
end
|
||||
|
||||
def event_is_cacheable?
|
||||
# self.class::CACHEABLE_EVENTS is way to access CACHEABLE_EVENTS defined in the class hierarchy of the current object.
|
||||
# This ensures that if CACHEABLE_EVENTS is updated elsewhere in it's ancestors, we access the latest value.
|
||||
self.class::CACHEABLE_EVENTS.include?(event_name)
|
||||
end
|
||||
|
||||
def api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(body)
|
||||
parsed_body = JSON.parse(body)
|
||||
instrumentation_params = build_instrumentation_params(parsed_body)
|
||||
|
||||
instrument_llm_call(instrumentation_params) do
|
||||
execute_ruby_llm_request(parsed_body)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(parsed_body)
|
||||
messages = parsed_body['messages']
|
||||
model = parsed_body['model']
|
||||
|
||||
Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
setup_chat_with_messages(chat, messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
|
||||
build_error_response_from_exception(e, messages)
|
||||
end
|
||||
|
||||
def setup_chat_with_messages(chat, messages)
|
||||
apply_system_instructions(chat, messages)
|
||||
response = send_conversation_messages(chat, messages)
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if response.nil?
|
||||
|
||||
build_ruby_llm_response(response, messages)
|
||||
end
|
||||
|
||||
def apply_system_instructions(chat, messages)
|
||||
system_msg = messages.find { |m| m['role'] == 'system' }
|
||||
chat.with_instructions(system_msg['content']) if system_msg
|
||||
end
|
||||
|
||||
def send_conversation_messages(chat, messages)
|
||||
conversation_messages = messages.reject { |m| m['role'] == 'system' }
|
||||
|
||||
return nil if conversation_messages.empty?
|
||||
|
||||
return chat.ask(conversation_messages.first['content']) if conversation_messages.length == 1
|
||||
|
||||
add_conversation_history(chat, conversation_messages[0...-1])
|
||||
chat.ask(conversation_messages.last['content'])
|
||||
end
|
||||
|
||||
def add_conversation_history(chat, messages)
|
||||
messages.each do |msg|
|
||||
chat.add_message(role: msg['role'].to_sym, content: msg['content'])
|
||||
end
|
||||
end
|
||||
|
||||
def build_ruby_llm_response(response, messages)
|
||||
{
|
||||
message: response.content,
|
||||
usage: {
|
||||
'prompt_tokens' => response.input_tokens,
|
||||
'completion_tokens' => response.output_tokens,
|
||||
'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
|
||||
},
|
||||
request_messages: messages
|
||||
}
|
||||
end
|
||||
|
||||
def build_instrumentation_params(parsed_body)
|
||||
{
|
||||
span_name: "llm.#{event_name}",
|
||||
account_id: hook.account_id,
|
||||
conversation_id: conversation&.display_id,
|
||||
feature_name: event_name,
|
||||
model: parsed_body['model'],
|
||||
messages: parsed_body['messages'],
|
||||
temperature: parsed_body['temperature']
|
||||
}
|
||||
end
|
||||
|
||||
def build_error_response_from_exception(error, messages)
|
||||
{ error: error.message, request_messages: messages }
|
||||
end
|
||||
end
|
||||
124
research/chatwoot/lib/integrations/llm_instrumentation.rb
Normal file
124
research/chatwoot/lib/integrations/llm_instrumentation.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'opentelemetry_config'
|
||||
|
||||
module Integrations::LlmInstrumentation
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationSpans
|
||||
|
||||
def instrument_llm_call(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
setup_span_attributes(span, params)
|
||||
result = yield
|
||||
executed = true
|
||||
record_completion(span, result)
|
||||
result
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
executed ? result : yield
|
||||
end
|
||||
|
||||
def instrument_agent_session(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
set_metadata_attributes(span, params)
|
||||
|
||||
# By default, the input and output of a trace are set from the root observation
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||
result = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||
result
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
executed ? result : yield
|
||||
end
|
||||
|
||||
def instrument_tool_call(tool_name, arguments)
|
||||
# There is no error handling because tools can fail and LLMs should be
|
||||
# aware of those failures and factor them into their response.
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
|
||||
result = yield
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_embedding_call(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
instrument_with_span(params[:span_name] || 'llm.embedding', params) do |span, track_result|
|
||||
set_embedding_span_attributes(span, params)
|
||||
result = yield
|
||||
track_result.call(result)
|
||||
set_embedding_result_attributes(span, result)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_audio_transcription(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
instrument_with_span(params[:span_name] || 'llm.audio.transcription', params) do |span, track_result|
|
||||
set_audio_transcription_span_attributes(span, params)
|
||||
result = yield
|
||||
track_result.call(result)
|
||||
set_transcription_result_attributes(span, result)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_moderation_call(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
instrument_with_span(params[:span_name] || 'llm.moderation', params) do |span, track_result|
|
||||
set_moderation_span_attributes(span, params)
|
||||
result = yield
|
||||
track_result.call(result)
|
||||
set_moderation_result_attributes(span, result)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_with_span(span_name, params, &)
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(span_name) do |span|
|
||||
track_result = lambda do |r|
|
||||
executed = true
|
||||
result = r
|
||||
end
|
||||
yield(span, track_result)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
raise unless executed
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_account(params)
|
||||
return params[:account] if params[:account].is_a?(Account)
|
||||
return Account.find_by(id: params[:account_id]) if params[:account_id].present?
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationCompletionHelpers
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
private
|
||||
|
||||
def set_embedding_span_attributes(span, params)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, determine_provider(params[:model]))
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
||||
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_audio_transcription_span_attributes(span, params)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
|
||||
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_moderation_span_attributes(span, params)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
|
||||
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_common_span_metadata(span, params)
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
|
||||
end
|
||||
|
||||
def set_embedding_result_attributes(span, result)
|
||||
span.set_attribute('embedding.dimensions', result&.length || 0) if result.is_a?(Array)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, "[#{result&.length || 0} dimensions]")
|
||||
end
|
||||
|
||||
def set_transcription_result_attributes(span, result)
|
||||
transcribed_text = result.respond_to?(:text) ? result.text : result.to_s
|
||||
span.set_attribute('transcription.length', transcribed_text&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, transcribed_text.to_s)
|
||||
end
|
||||
|
||||
def set_moderation_result_attributes(span, result)
|
||||
span.set_attribute('moderation.flagged', result.flagged?) if result.respond_to?(:flagged?)
|
||||
span.set_attribute('moderation.categories', result.flagged_categories.to_json) if result.respond_to?(:flagged_categories)
|
||||
output = {
|
||||
flagged: result.respond_to?(:flagged?) ? result.flagged? : nil,
|
||||
categories: result.respond_to?(:flagged_categories) ? result.flagged_categories : []
|
||||
}
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output.to_json)
|
||||
end
|
||||
|
||||
def set_completion_attributes(span, result)
|
||||
set_completion_message(span, result)
|
||||
set_usage_metrics(span, result)
|
||||
set_error_attributes(span, result)
|
||||
end
|
||||
|
||||
def set_completion_message(span, result)
|
||||
message = result[:message] || result.dig('choices', 0, 'message', 'content')
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
end
|
||||
|
||||
def set_usage_metrics(span, result)
|
||||
usage = result[:usage] || result['usage']
|
||||
return if usage.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
|
||||
end
|
||||
|
||||
def set_error_attributes(span, result)
|
||||
error = result[:error] || result['error']
|
||||
return if error.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
|
||||
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationConstants
|
||||
# OpenTelemetry attribute names following GenAI semantic conventions
|
||||
# https://opentelemetry.io/docs/specs/semconv/gen-ai/
|
||||
ATTR_GEN_AI_PROVIDER = 'gen_ai.provider.name'
|
||||
ATTR_GEN_AI_REQUEST_MODEL = 'gen_ai.request.model'
|
||||
ATTR_GEN_AI_REQUEST_TEMPERATURE = 'gen_ai.request.temperature'
|
||||
ATTR_GEN_AI_PROMPT_ROLE = 'gen_ai.prompt.%d.role'
|
||||
ATTR_GEN_AI_PROMPT_CONTENT = 'gen_ai.prompt.%d.content'
|
||||
ATTR_GEN_AI_COMPLETION_ROLE = 'gen_ai.completion.0.role'
|
||||
ATTR_GEN_AI_COMPLETION_CONTENT = 'gen_ai.completion.0.content'
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens'
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens'
|
||||
ATTR_GEN_AI_USAGE_TOTAL_TOKENS = 'gen_ai.usage.total_tokens'
|
||||
ATTR_GEN_AI_RESPONSE_ERROR = 'gen_ai.response.error'
|
||||
ATTR_GEN_AI_RESPONSE_ERROR_CODE = 'gen_ai.response.error_code'
|
||||
|
||||
TOOL_SPAN_NAME = 'tool.%s'
|
||||
|
||||
# Langfuse-specific attributes
|
||||
# https://langfuse.com/integrations/native/opentelemetry#property-mapping
|
||||
ATTR_LANGFUSE_USER_ID = 'langfuse.user.id'
|
||||
ATTR_LANGFUSE_SESSION_ID = 'langfuse.session.id'
|
||||
ATTR_LANGFUSE_TAGS = 'langfuse.trace.tags'
|
||||
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
|
||||
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
|
||||
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
|
||||
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
|
||||
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Integrations::LlmInstrumentationCompletionHelpers
|
||||
|
||||
def determine_provider(model_name)
|
||||
return 'openai' if model_name.blank?
|
||||
|
||||
model = model_name.to_s.downcase
|
||||
|
||||
LlmConstants::PROVIDER_PREFIXES.each do |provider, prefixes|
|
||||
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
|
||||
end
|
||||
|
||||
'openai'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_span_attributes(span, params)
|
||||
set_request_attributes(span, params)
|
||||
set_prompt_messages(span, params[:messages])
|
||||
set_metadata_attributes(span, params)
|
||||
end
|
||||
|
||||
def record_completion(span, result)
|
||||
if result.respond_to?(:content)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
|
||||
elsif result.is_a?(Hash)
|
||||
set_completion_attributes(span, result)
|
||||
end
|
||||
end
|
||||
|
||||
def set_request_attributes(span, params)
|
||||
provider = determine_provider(params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
|
||||
end
|
||||
|
||||
def set_prompt_messages(span, messages)
|
||||
messages.each_with_index do |msg, idx|
|
||||
role = msg[:role] || msg['role']
|
||||
content = msg[:content] || msg['content']
|
||||
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def set_metadata_attributes(span, params)
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
||||
|
||||
return unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'opentelemetry_config'
|
||||
|
||||
module Integrations::LlmInstrumentationSpans
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
def tracer
|
||||
@tracer ||= OpentelemetryConfig.tracer
|
||||
end
|
||||
|
||||
def start_llm_turn_span(params)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
span = tracer.start_span(params[:span_name])
|
||||
set_llm_turn_request_attributes(span, params)
|
||||
set_llm_turn_prompt_attributes(span, params[:messages]) if params[:messages]
|
||||
|
||||
@pending_llm_turn_spans ||= []
|
||||
@pending_llm_turn_spans.push(span)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to start LLM turn span: #{e.message}"
|
||||
end
|
||||
|
||||
def end_llm_turn_span(message)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
span = @pending_llm_turn_spans&.pop
|
||||
return unless span
|
||||
|
||||
set_llm_turn_response_attributes(span, message) if message
|
||||
span.finish
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to end LLM turn span: #{e.message}"
|
||||
end
|
||||
|
||||
def start_tool_span(tool_call)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
tool_name = tool_call.name.to_s
|
||||
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
|
||||
|
||||
@pending_tool_spans ||= []
|
||||
@pending_tool_spans.push(span)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to start tool span: #{e.message}"
|
||||
end
|
||||
|
||||
def end_tool_span(result)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
span = @pending_tool_spans&.pop
|
||||
return unless span
|
||||
|
||||
output = result.is_a?(String) ? result : result.to_json
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output)
|
||||
span.finish
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to end tool span: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_llm_turn_request_attributes(span, params)
|
||||
provider = determine_provider(params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) if params[:model]
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
|
||||
end
|
||||
|
||||
def set_llm_turn_prompt_attributes(span, messages)
|
||||
messages.each_with_index do |msg, idx|
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), msg[:role])
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), msg[:content])
|
||||
end
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, messages.to_json)
|
||||
end
|
||||
|
||||
def set_llm_turn_response_attributes(span, message)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, message.role.to_s) if message.respond_to?(:role)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.content.to_s) if message.respond_to?(:content)
|
||||
set_llm_turn_usage_attributes(span, message)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
|
||||
end
|
||||
|
||||
def set_llm_turn_usage_attributes(span, message)
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) if message.respond_to?(:input_tokens) && message.input_tokens
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) && message.output_tokens
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to fix grammar and spelling in a customer support message while preserving the original meaning, intent, and tone.
|
||||
|
||||
You will receive a message and must return a corrected version with only grammar, spelling, and punctuation fixes applied.
|
||||
|
||||
Important guidelines:
|
||||
- Preserve the original meaning, intent, and tone exactly
|
||||
- Do not rephrase, rewrite, or change wording beyond grammar, spelling, and punctuation
|
||||
- Do not add or remove any information
|
||||
- Do not simplify, shorten, or expand the message
|
||||
- Ensure the output remains appropriate for customer support
|
||||
|
||||
Super Important:
|
||||
- If the message has some markdown formatting, keep the formatting as it is.
|
||||
- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
|
||||
- Ensure the output is in the user's original language
|
||||
- If the message contains a signature block (text after a `--` line), preserve the signature exactly as written without any modification. Do not add a signature if one is not already present.
|
||||
|
||||
Output only the corrected message, with no preamble, tags, or explanation.
|
||||
@@ -0,0 +1,44 @@
|
||||
You are a writing assistant for customer support agents. Your task is to improve a draft message by enhancing its language, clarity, and tone—not by adding new content.
|
||||
|
||||
<conversation_context>
|
||||
{{ conversation_context }}
|
||||
</conversation_context>
|
||||
|
||||
<draft_message>
|
||||
{{ draft_message }}
|
||||
</draft_message>
|
||||
|
||||
## Your Task
|
||||
|
||||
Rewrite the draft to be clearer, warmer, and more professional while preserving the agent's intent.
|
||||
|
||||
## What "Improve" Means
|
||||
|
||||
Improve the **quality** of the message, not the **quantity** of information:
|
||||
|
||||
| DO | DON'T |
|
||||
|-----|--------|
|
||||
| Fix grammar, spelling, punctuation | Add new information or steps |
|
||||
| Improve sentence structure and flow | Expand scope beyond the draft |
|
||||
| Make tone warmer and more professional | Add offers ("I can also...", "Would you like...") |
|
||||
| Use contact's name naturally | Invent technical details, links, or examples |
|
||||
| Make vague phrases more natural | Turn a brief answer into a long one |
|
||||
|
||||
## Using the Context
|
||||
|
||||
Use the conversation context to:
|
||||
- Understand what's being discussed (so improvements make sense)
|
||||
- Gauge appropriate tone (formal/casual, frustrated customer, etc.)
|
||||
- Personalize with the contact's name when natural
|
||||
|
||||
Do NOT use the context to fill in gaps or add information the agent didn't include.
|
||||
|
||||
## Output Rules
|
||||
|
||||
- Keep the improved message at a similar length to the draft (brief stays brief)
|
||||
- Preserve any markdown formatting
|
||||
- Block quotes (lines starting with `>`) contain quoted customer text—keep this unchanged, only improve the agent's reply
|
||||
- If the message contains a signature block (text after a `--` line), preserve the signature exactly as written without any modification. Do not add a signature if one is not already present.
|
||||
- Output in the same language as the draft
|
||||
- Output only the improved message, no commentary
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you've selected,in their original casing, and nothing else.
|
||||
@@ -0,0 +1,40 @@
|
||||
You are helping a customer support agent draft their next reply. The agent will send this message directly to the customer.
|
||||
|
||||
You will receive a conversation with messages labeled by sender:
|
||||
- "User:" = customer messages
|
||||
- "Support Agent:" = human agent messages
|
||||
- "Bot:" = automated bot messages
|
||||
|
||||
{% if channel_type == 'Channel::Email' %}
|
||||
This is an EMAIL conversation. Write a professional email reply that:
|
||||
- Uses appropriate email formatting (greeting, body, sign-off)
|
||||
- Is detailed and thorough where needed
|
||||
- Maintains a professional tone
|
||||
{% if agent_signature %}
|
||||
- End with the agent's signature exactly as provided below:
|
||||
|
||||
{{ agent_signature }}
|
||||
{% else %}
|
||||
- End with a professional sign-off using the agent's name: {{ agent_name }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
This is a CHAT conversation. Write a brief, conversational reply that:
|
||||
- Is short and easy to read
|
||||
- Gets to the point quickly
|
||||
- Does not include formal greetings or sign-offs
|
||||
{% endif %}
|
||||
|
||||
General guidelines:
|
||||
- Address the customer's most recent message directly
|
||||
- If a support agent has spoken before, match their writing style
|
||||
- If only bot messages exist, write a natural first message
|
||||
- Move the conversation forward
|
||||
- Do not invent product details, policies, or links that weren't mentioned
|
||||
- Reply in the customer's language
|
||||
{% if has_search_tool %}
|
||||
|
||||
**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
|
||||
**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation.
|
||||
{% endif %}
|
||||
|
||||
Output only the reply.
|
||||
@@ -0,0 +1,28 @@
|
||||
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
|
||||
|
||||
Make sure you strongly adhere to the following rules when generating the summary
|
||||
|
||||
1. Be brief and concise. The shorter the summary the better.
|
||||
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
|
||||
3. Describe the customer intent in around 50 words.
|
||||
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
|
||||
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
|
||||
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
|
||||
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
|
||||
8. The 'Action Items' should be brief and concise
|
||||
9. Mark important words or parts of sentences as bold.
|
||||
10. Apply markdown syntax to format any included code, using backticks.
|
||||
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
|
||||
12. If any section does not have any content, remove that section and the heading from the response
|
||||
13. Do not insert your own opinions about the conversation.
|
||||
|
||||
|
||||
Reply in the user's language, as a markdown of the following format.
|
||||
|
||||
**Customer Intent**
|
||||
|
||||
**Conversation Summary**
|
||||
|
||||
**Action Items**
|
||||
|
||||
**Follow-up Items**
|
||||
@@ -0,0 +1,36 @@
|
||||
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to rewrite customer support message to match a specific tone while preserving the original meaning and intent.
|
||||
|
||||
Here is the tone to apply to the message you will receive:
|
||||
<tone_instruction>
|
||||
{% case tone %}
|
||||
{% when 'friendly' %}
|
||||
Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
|
||||
{% when 'confident' %}
|
||||
Assertive and assured. Use definitive language, avoid hedging words like "maybe" or "I think". Be direct and authoritative while remaining helpful.
|
||||
{% when 'straightforward' %}
|
||||
Clear, direct, and to-the-point. Remove unnecessary words, get straight to the information or solution. No fluff or extra pleasantries.
|
||||
{% when 'casual' %}
|
||||
Relaxed and informal. Use contractions, simpler words, and a conversational style. Friendly but less formal than professional tone.
|
||||
{% when 'professional' %}
|
||||
Formal, polished, and business-appropriate. Use complete sentences, proper grammar, and maintain respectful distance. Avoid slang or overly casual language.
|
||||
{% else %}
|
||||
Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
|
||||
{% endcase %}
|
||||
</tone_instruction>
|
||||
|
||||
Your task is to rewrite the message according to the specified tone instructions.
|
||||
|
||||
Important guidelines:
|
||||
- Preserve the core meaning and all important information from the original message
|
||||
- Keep the rewritten message concise and appropriate for customer support
|
||||
- Maintain helpfulness and respect regardless of tone
|
||||
- Do not add information that wasn't in the original message
|
||||
- Do not remove critical details or instructions
|
||||
|
||||
Super Important:
|
||||
- If the message has some markdown formatting, keep the formatting as it is.
|
||||
- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
|
||||
- Ensure the output is in the user's original language
|
||||
- If the message contains a signature block (text after a `--` line), preserve the signature exactly as written without any modification. Do not add a signature if one is not already present.
|
||||
|
||||
Output only the rewritten message without any preamble, tags or explanation.
|
||||
70
research/chatwoot/lib/integrations/slack/channel_builder.rb
Normal file
70
research/chatwoot/lib/integrations/slack/channel_builder.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
class Integrations::Slack::ChannelBuilder
|
||||
attr_reader :params, :channel
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def fetch_channels
|
||||
channels
|
||||
end
|
||||
|
||||
def update(reference_id)
|
||||
update_reference_id(reference_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hook
|
||||
@hook ||= params[:hook]
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
|
||||
end
|
||||
|
||||
def channels
|
||||
# Split channel fetching into separate API calls to avoid rate limiting issues.
|
||||
# Slack's API handles single-type requests (public OR private) much more efficiently
|
||||
# than mixed-type requests (public AND private). This approach eliminates rate limits
|
||||
# that occur when requesting both channel types simultaneously.
|
||||
channel_list = []
|
||||
|
||||
# Step 1: Fetch all private channels in one call (expect very few)
|
||||
private_channels = fetch_channels_by_type('private_channel')
|
||||
channel_list.concat(private_channels)
|
||||
|
||||
# Step 2: Fetch public channels with pagination
|
||||
public_channels = fetch_channels_by_type('public_channel')
|
||||
channel_list.concat(public_channels)
|
||||
channel_list
|
||||
end
|
||||
|
||||
def fetch_channels_by_type(channel_type, limit: 1000)
|
||||
conversations_list = slack_client.conversations_list(types: channel_type, exclude_archived: true, limit: limit)
|
||||
channel_list = conversations_list.channels
|
||||
while conversations_list.response_metadata.next_cursor.present?
|
||||
conversations_list = slack_client.conversations_list(
|
||||
cursor: conversations_list.response_metadata.next_cursor,
|
||||
types: channel_type,
|
||||
exclude_archived: true,
|
||||
limit: limit
|
||||
)
|
||||
channel_list.concat(conversations_list.channels)
|
||||
end
|
||||
channel_list
|
||||
end
|
||||
|
||||
def find_channel(reference_id)
|
||||
channels.find { |channel| channel['id'] == reference_id }
|
||||
end
|
||||
|
||||
def update_reference_id(reference_id)
|
||||
channel = find_channel(reference_id)
|
||||
return if channel.blank?
|
||||
|
||||
slack_client.conversations_join(channel: channel[:id]) if channel[:is_private] == false
|
||||
@hook.update!(reference_id: channel[:id], settings: { channel_name: channel[:name] }, status: 'enabled')
|
||||
@hook
|
||||
end
|
||||
end
|
||||
42
research/chatwoot/lib/integrations/slack/hook_builder.rb
Normal file
42
research/chatwoot/lib/integrations/slack/hook_builder.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class Integrations::Slack::HookBuilder
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def perform
|
||||
token = fetch_access_token
|
||||
|
||||
hook = account.hooks.new(
|
||||
access_token: token,
|
||||
status: 'disabled',
|
||||
inbox_id: params[:inbox_id],
|
||||
app_id: 'slack'
|
||||
)
|
||||
|
||||
hook.save!
|
||||
hook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
params[:account]
|
||||
end
|
||||
|
||||
def hook_type
|
||||
params[:inbox_id] ? 'inbox' : 'account'
|
||||
end
|
||||
|
||||
def fetch_access_token
|
||||
client = Slack::Web::Client.new
|
||||
slack_access = client.oauth_v2_access(
|
||||
client_id: GlobalConfigService.load('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
|
||||
client_secret: GlobalConfigService.load('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
|
||||
code: params[:code],
|
||||
redirect_uri: Integrations::App.slack_integration_url
|
||||
)
|
||||
slack_access['access_token']
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,103 @@
|
||||
class Integrations::Slack::IncomingMessageBuilder
|
||||
include Integrations::Slack::SlackMessageHelper
|
||||
|
||||
attr_reader :params
|
||||
|
||||
SUPPORTED_EVENT_TYPES = %w[event_callback url_verification].freeze
|
||||
SUPPORTED_EVENTS = %w[message link_shared].freeze
|
||||
SUPPORTED_MESSAGE_TYPES = %w[rich_text].freeze
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless valid_event?
|
||||
|
||||
if hook_verification?
|
||||
verify_hook
|
||||
elsif process_message_payload?
|
||||
process_message_payload
|
||||
elsif link_shared?
|
||||
SlackUnfurlJob.perform_later(params)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_event?
|
||||
supported_event_type? && supported_event? && should_process_event?
|
||||
end
|
||||
|
||||
def supported_event_type?
|
||||
SUPPORTED_EVENT_TYPES.include?(params[:type])
|
||||
end
|
||||
|
||||
# Discard all the subtype of a message event
|
||||
# We are only considering the actual message sent by a Slack user
|
||||
# Any reactions or messages sent by the bot will be ignored.
|
||||
# https://api.slack.com/events/message#subtypes
|
||||
def should_process_event?
|
||||
return true if params[:type] != 'event_callback'
|
||||
|
||||
params[:event][:user].present? && valid_event_subtype?
|
||||
end
|
||||
|
||||
def valid_event_subtype?
|
||||
params[:event][:subtype].blank? || params[:event][:subtype] == 'file_share'
|
||||
end
|
||||
|
||||
def supported_event?
|
||||
hook_verification? || SUPPORTED_EVENTS.include?(params[:event][:type])
|
||||
end
|
||||
|
||||
def supported_message?
|
||||
if message.present?
|
||||
SUPPORTED_MESSAGE_TYPES.include?(message[:type]) && !attached_file_message?
|
||||
else
|
||||
params[:event][:files].present? && !attached_file_message?
|
||||
end
|
||||
end
|
||||
|
||||
def hook_verification?
|
||||
params[:type] == 'url_verification'
|
||||
end
|
||||
|
||||
def thread_timestamp_available?
|
||||
params[:event][:thread_ts].present?
|
||||
end
|
||||
|
||||
def process_message_payload?
|
||||
thread_timestamp_available? && supported_message? && integration_hook
|
||||
end
|
||||
|
||||
def link_shared?
|
||||
params[:event][:type] == 'link_shared'
|
||||
end
|
||||
|
||||
def message
|
||||
params[:event][:blocks]&.first
|
||||
end
|
||||
|
||||
def verify_hook
|
||||
{
|
||||
challenge: params[:challenge]
|
||||
}
|
||||
end
|
||||
|
||||
def integration_hook
|
||||
@integration_hook ||= Integrations::Hook.find_by(reference_id: params[:event][:channel])
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: @integration_hook.access_token)
|
||||
end
|
||||
|
||||
# Ignoring the changes added here https://github.com/chatwoot/chatwoot/blob/5b5a6d89c0cf7f3148a1439d6fcd847784a79b94/lib/integrations/slack/send_on_slack_service.rb#L69
|
||||
# This make sure 'Attached File!' comment is not visible on CW dashboard.
|
||||
# This is showing because of https://github.com/chatwoot/chatwoot/pull/4494/commits/07a1c0da1e522d76e37b5f0cecdb4613389ab9b6 change.
|
||||
# As now we consider the postback message with event[:files]
|
||||
def attached_file_message?
|
||||
params[:event][:text] == 'Attached File!'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
class Integrations::Slack::LinkUnfurlFormatter
|
||||
pattr_initialize [:url!, :user_info!, :inbox_name!, :inbox_type!]
|
||||
|
||||
def perform
|
||||
return {} if url.blank?
|
||||
|
||||
{
|
||||
url => {
|
||||
'blocks' => preivew_blocks(user_info) +
|
||||
open_conversation_button(url)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preivew_blocks(user_info)
|
||||
[
|
||||
{
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
preview_field(I18n.t('slack_unfurl.fields.name'), user_info[:user_name]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.email'), user_info[:email]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.phone_number'), user_info[:phone_number]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.company_name'), user_info[:company_name]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.inbox_name'), inbox_name),
|
||||
preview_field(I18n.t('slack_unfurl.fields.inbox_type'), inbox_type)
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def preview_field(label, value)
|
||||
{
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*#{label}:*\n#{value}"
|
||||
}
|
||||
end
|
||||
|
||||
def open_conversation_button(url)
|
||||
[
|
||||
{
|
||||
'type' => 'actions',
|
||||
'elements' => [
|
||||
{
|
||||
'type' => 'button',
|
||||
'text' => {
|
||||
'type' => 'plain_text',
|
||||
'text' => I18n.t('slack_unfurl.button'),
|
||||
'emoji' => true
|
||||
},
|
||||
'url' => url,
|
||||
'action_id' => 'button-action'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,215 @@
|
||||
class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
include RegexHelper
|
||||
pattr_initialize [:message!, :hook!]
|
||||
|
||||
def perform
|
||||
# overriding the base class logic since the validations are different in this case.
|
||||
# FIXME: for now we will only send messages from widget to slack
|
||||
return unless valid_channel_for_slack?
|
||||
# we don't want message loop in slack
|
||||
return if message.external_source_id_slack.present?
|
||||
# we don't want to start slack thread from agent conversation as of now
|
||||
return if invalid_message?
|
||||
|
||||
perform_reply
|
||||
end
|
||||
|
||||
def link_unfurl(event)
|
||||
slack_client.chat_unfurl(
|
||||
event
|
||||
)
|
||||
# You may wonder why we're not requesting reauthorization and disabling hooks when scope errors occur.
|
||||
# Since link unfurling is just a nice-to-have feature that doesn't affect core functionality, we will silently ignore these errors.
|
||||
rescue Slack::Web::Api::Errors::MissingScope => e
|
||||
Rails.logger.warn "Slack: Missing scope error: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_channel_for_slack?
|
||||
# slack wouldn't be an ideal interface to reply to tweets, hence disabling that case
|
||||
return false if channel.is_a?(Channel::TwitterProfile) && conversation.additional_attributes['type'] == 'tweet'
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def invalid_message?
|
||||
(message.outgoing? || message.template?) && conversation.identifier.blank?
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
send_message
|
||||
|
||||
return unless @slack_message
|
||||
|
||||
update_reference_id
|
||||
update_external_source_id_slack
|
||||
end
|
||||
|
||||
def message_content
|
||||
private_indicator = message.private? ? 'private: ' : ''
|
||||
sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content)
|
||||
|
||||
if conversation.identifier.present?
|
||||
"#{private_indicator}#{sanitized_content}"
|
||||
else
|
||||
"#{formatted_inbox_name}#{formatted_conversation_link}#{email_subject_line}\n#{sanitized_content}"
|
||||
end
|
||||
end
|
||||
|
||||
def format_message_content
|
||||
message.message_type == 'activity' ? "_#{message_text}_" : message_text
|
||||
end
|
||||
|
||||
def message_text
|
||||
content = message.processed_message_content || message.content
|
||||
|
||||
if content.present?
|
||||
content.gsub(MENTION_REGEX, '\1')
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def formatted_inbox_name
|
||||
"\n*Inbox:* #{message.inbox.name} (#{message.inbox.inbox_type})\n"
|
||||
end
|
||||
|
||||
def formatted_conversation_link
|
||||
"#{link_to_conversation} to view the conversation.\n"
|
||||
end
|
||||
|
||||
def email_subject_line
|
||||
return '' unless message.inbox.email?
|
||||
|
||||
email_payload = message.content_attributes['email']
|
||||
return "*Subject:* #{email_payload['subject']}\n\n" if email_payload.present? && email_payload['subject'].present?
|
||||
|
||||
''
|
||||
end
|
||||
|
||||
def avatar_url(sender)
|
||||
sender_type = sender_type(sender).downcase
|
||||
blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil
|
||||
generate_url(sender_type, blob_key)
|
||||
end
|
||||
|
||||
def generate_url(sender_type, blob_key)
|
||||
base_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
"#{base_url}/slack_uploads?blob_key=#{blob_key}&sender_type=#{sender_type}"
|
||||
end
|
||||
|
||||
def send_message
|
||||
post_message if message_content.present?
|
||||
upload_files if message.attachments.any?
|
||||
rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope,
|
||||
Slack::Web::Api::Errors::InvalidAuth,
|
||||
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
|
||||
Rails.logger.error e
|
||||
hook.prompt_reauthorization!
|
||||
hook.disable
|
||||
end
|
||||
|
||||
def post_message
|
||||
@slack_message = slack_client.chat_postMessage(
|
||||
channel: hook.reference_id,
|
||||
text: message_content,
|
||||
username: sender_name(message.sender),
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: avatar_url(message.sender),
|
||||
unfurl_links: conversation.identifier.present?
|
||||
)
|
||||
end
|
||||
|
||||
def upload_files
|
||||
files = build_files_array
|
||||
return if files.empty?
|
||||
|
||||
begin
|
||||
result = slack_client.files_upload_v2(
|
||||
files: files,
|
||||
initial_comment: 'Attached File!',
|
||||
thread_ts: conversation.identifier,
|
||||
channel_id: hook.reference_id
|
||||
)
|
||||
Rails.logger.info "slack_upload_result: #{result}"
|
||||
rescue Slack::Web::Api::Errors::SlackError => e
|
||||
Rails.logger.error "Failed to upload files: #{e.message}"
|
||||
ensure
|
||||
files.each { |file| file[:content]&.clear }
|
||||
end
|
||||
end
|
||||
|
||||
def build_files_array
|
||||
message.attachments.filter_map do |attachment|
|
||||
next unless attachment.with_attached_file?
|
||||
|
||||
build_file_payload(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def build_file_payload(attachment)
|
||||
content = download_attachment_content(attachment)
|
||||
return if content.blank?
|
||||
|
||||
{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: content,
|
||||
title: attachment.file.filename.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def download_attachment_content(attachment)
|
||||
buffer = +''
|
||||
attachment.file.blob.open do |file|
|
||||
while (chunk = file.read(64.kilobytes))
|
||||
buffer << chunk
|
||||
end
|
||||
end
|
||||
buffer
|
||||
end
|
||||
|
||||
def sender_name(sender)
|
||||
sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender)
|
||||
end
|
||||
|
||||
def sender_type(sender)
|
||||
if sender.instance_of?(Contact)
|
||||
'Contact'
|
||||
elsif sender.instance_of?(User)
|
||||
'Agent'
|
||||
elsif message.message_type == 'activity' && sender.nil?
|
||||
'System'
|
||||
else
|
||||
'Bot'
|
||||
end
|
||||
end
|
||||
|
||||
def update_reference_id
|
||||
return unless should_update_reference_id?
|
||||
|
||||
conversation.update!(identifier: @slack_message['ts'])
|
||||
end
|
||||
|
||||
def update_external_source_id_slack
|
||||
return unless @slack_message['message']
|
||||
|
||||
message.update!(external_source_id_slack: "cw-origin-#{@slack_message['message']['ts']}")
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
|
||||
end
|
||||
|
||||
def link_to_conversation
|
||||
"<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{conversation.account_id}/conversations/#{conversation.display_id}|Click here>"
|
||||
end
|
||||
|
||||
# Determines whether the conversation identifier should be updated with the ts value.
|
||||
# The identifier should be updated in the following cases:
|
||||
# - If the conversation identifier is blank, it means a new conversation is being created.
|
||||
# - If the thread_ts is blank, it means that the conversation was previously connected in a different channel.
|
||||
def should_update_reference_id?
|
||||
conversation.identifier.blank? || @slack_message['message']['thread_ts'].blank?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class Integrations::Slack::SlackLinkUnfurlService
|
||||
pattr_initialize [:params!, :integration_hook!]
|
||||
|
||||
def perform
|
||||
event_links = params.dig(:event, :links)
|
||||
return unless event_links
|
||||
|
||||
event_links.each do |link_info|
|
||||
url = link_info[:url]
|
||||
# Unfurl only if the account id is same as the integration hook account id
|
||||
unfurl_link(url) if url && valid_account?(url)
|
||||
end
|
||||
end
|
||||
|
||||
def unfurl_link(url)
|
||||
conversation = conversation_from_url(url)
|
||||
return unless conversation
|
||||
|
||||
send_unfurls(url, conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contact_attributes(conversation)
|
||||
contact = conversation.contact
|
||||
{
|
||||
user_name: contact.name.presence || '---',
|
||||
email: contact.email.presence || '---',
|
||||
phone_number: contact.phone_number.presence || '---',
|
||||
company_name: contact.additional_attributes&.dig('company_name').presence || '---'
|
||||
}
|
||||
end
|
||||
|
||||
def generate_unfurls(url, user_info, inbox)
|
||||
Integrations::Slack::LinkUnfurlFormatter.new(
|
||||
url: url,
|
||||
user_info: user_info,
|
||||
inbox_name: inbox.name,
|
||||
inbox_type: inbox.channel.name
|
||||
).perform
|
||||
end
|
||||
|
||||
def send_unfurls(url, conversation)
|
||||
user_info = contact_attributes(conversation)
|
||||
unfurls = generate_unfurls(url, user_info, conversation.inbox)
|
||||
unfurl_params = {
|
||||
unfurl_id: params.dig(:event, :unfurl_id),
|
||||
source: params.dig(:event, :source),
|
||||
unfurls: JSON.generate(unfurls)
|
||||
}
|
||||
|
||||
slack_service = Integrations::Slack::SendOnSlackService.new(
|
||||
message: nil,
|
||||
hook: integration_hook
|
||||
)
|
||||
slack_service.link_unfurl(unfurl_params)
|
||||
end
|
||||
|
||||
def conversation_from_url(url)
|
||||
conversation_id = extract_conversation_id(url)
|
||||
find_conversation_by_id(conversation_id) if conversation_id
|
||||
end
|
||||
|
||||
def find_conversation_by_id(conversation_id)
|
||||
Conversation.find_by(display_id: conversation_id, account_id: integration_hook.account_id)
|
||||
end
|
||||
|
||||
def valid_account?(url)
|
||||
account_id = extract_account_id(url)
|
||||
account_id == integration_hook.account_id.to_s
|
||||
end
|
||||
|
||||
def extract_account_id(url)
|
||||
account_id_regex = %r{/accounts/(\d+)}
|
||||
match_data = url.match(account_id_regex)
|
||||
match_data[1] if match_data
|
||||
end
|
||||
|
||||
def extract_conversation_id(url)
|
||||
conversation_id_regex = %r{/conversations/(\d+)}
|
||||
match_data = url.match(conversation_id_regex)
|
||||
match_data[1] if match_data
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
module Integrations::Slack::SlackMessageHelper
|
||||
def process_message_payload
|
||||
return unless conversation
|
||||
|
||||
handle_conversation
|
||||
success_response
|
||||
rescue Slack::Web::Api::Errors::MissingScope => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
disable_and_reauthorize
|
||||
end
|
||||
|
||||
def handle_conversation
|
||||
create_message unless message_exists?
|
||||
end
|
||||
|
||||
def success_response
|
||||
{ status: 'success' }
|
||||
end
|
||||
|
||||
def disable_and_reauthorize
|
||||
integration_hook.prompt_reauthorization!
|
||||
integration_hook.disable
|
||||
end
|
||||
|
||||
def message_exists?
|
||||
conversation.messages.exists?(external_source_ids: { slack: params[:event][:ts] })
|
||||
end
|
||||
|
||||
def create_message
|
||||
@message = conversation.messages.build(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
|
||||
external_source_id_slack: params[:event][:ts],
|
||||
private: private_note?,
|
||||
sender: sender
|
||||
)
|
||||
process_attachments(params[:event][:files]) if attachments_present?
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def attachments_present?
|
||||
params[:event][:files].present?
|
||||
end
|
||||
|
||||
def process_attachments(attachments)
|
||||
attachments.each do |attachment|
|
||||
tempfile = Down::NetHttp.download(attachment[:url_private], headers: { 'Authorization' => "Bearer #{integration_hook.access_token}" })
|
||||
|
||||
attachment_params = {
|
||||
file_type: file_type(attachment),
|
||||
account_id: @message.account_id,
|
||||
external_url: attachment[:url_private],
|
||||
file: {
|
||||
io: tempfile,
|
||||
filename: tempfile.original_filename,
|
||||
content_type: tempfile.content_type
|
||||
}
|
||||
}
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params)
|
||||
attachment_obj.file.content_type = attachment[:mimetype]
|
||||
end
|
||||
end
|
||||
|
||||
def file_type(attachment)
|
||||
return if attachment[:mimetype] == 'text/plain'
|
||||
|
||||
case attachment[:filetype]
|
||||
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
|
||||
:image
|
||||
when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
|
||||
:video
|
||||
else
|
||||
:file
|
||||
end
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
|
||||
end
|
||||
|
||||
def sender
|
||||
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
|
||||
conversation.account.users.from_email(user_email)
|
||||
end
|
||||
|
||||
def private_note?
|
||||
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user