Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View 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

View File

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

View File

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

View 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

View File

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

View File

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

View File

@@ -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"
# }
# }
# }

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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