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,62 @@
# Code inspired by
# http://royvandermeij.com/blog/2011/09/21/create-a-liquid-handler-for-rails-3-dot-1/
# https://github.com/chamnap/liquid-rails/blob/master/lib/liquid-rails/template_handler.rb
class ActionView::Template::Handlers::Liquid
def self.call(template, _source)
"ActionView::Template::Handlers::Liquid.new(self).render(#{template.source.inspect}, local_assigns)"
end
def initialize(view)
@view = view
@controller = @view.controller
@helper = ActionController::Base.helpers
end
def render(template, local_assigns = {})
assigns = drops
assigns['content_for_layout'] = @view.content_for(:layout) if @view.content_for?(:layout)
assigns.merge!(local_assigns)
assigns.merge!(locals)
liquid = Liquid::Template.parse(template)
liquid.send(render_method, assigns.stringify_keys, filters: filters, registers: registers.stringify_keys)
end
def locals
if @controller.respond_to?(:liquid_locals, true)
@controller.send(:liquid_locals)
else
{}
end
end
def drops
droppables = @controller.send(:liquid_droppables) if @controller.respond_to?(:liquid_droppables, true)
droppables.update(droppables) { |_, obj| obj.try(:to_drop) || nil }
end
def filters
if @controller.respond_to?(:liquid_filters, true)
@controller.send(:liquid_filters)
else
[]
end
end
def registers
if @controller.respond_to?(:liquid_registers, true)
@controller.send(:liquid_registers)
else
{}
end
end
def compilable?
false
end
def render_method
::Rails.env.development? || ::Rails.env.test? ? :render! : :render
end
end

View File

View File

@@ -0,0 +1,39 @@
class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
def image(node)
src, title = extract_img_attributes(node)
height = extract_image_height(src)
render_img_tag(src, title, height)
end
private
def extract_img_attributes(node)
[
escape_href(node.url),
escape_html(node.title)
]
end
def extract_image_height(src)
query_params = parse_query_params(src)
query_params['cw_image_height']&.first
end
def parse_query_params(url)
parsed_url = URI.parse(url)
CGI.parse(parsed_url.query || '')
rescue URI::InvalidURIError
{}
end
def render_img_tag(src, title, height = nil)
title_attribute = title.present? ? " title=\"#{title}\"" : ''
height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : ''
plain do
# plain ensures that the content is not wrapped in a paragraph tag
out("<img src=\"#{src}\"#{title_attribute}#{height_attribute} />")
end
end
end

View File

@@ -0,0 +1,189 @@
class Captain::BaseTaskService
include Integrations::LlmInstrumentation
include Captain::ToolInstrumentation
# 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
# Prepend enterprise module to subclasses when they're defined.
# This ensures the enterprise perform wrapper is applied even when
# subclasses define their own perform method, since prepend puts
# the module before the class in the ancestor chain.
def self.inherited(subclass)
super
subclass.prepend_mod_with('Captain::BaseTaskService')
end
pattr_initialize [:account!, { conversation_display_id: nil }]
private
def event_name
raise NotImplementedError, "#{self.class} must implement #event_name"
end
def conversation
@conversation ||= account.conversations.find_by(display_id: conversation_display_id)
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(model:, messages:, tools: [])
# Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
instrumentation_params = build_instrumentation_params(model, messages)
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
response = send(instrumentation_method, instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
end
return response unless build_follow_up_context? && response[:message].present?
response.merge(follow_up_context: build_follow_up_context(messages, response))
end
def execute_ruby_llm_request(model:, messages:, tools: [])
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
chat = build_chat(context, model: model, messages: messages, tools: tools)
conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
add_messages_if_needed(chat, conversation_messages)
build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception
{ error: e.message, request_messages: messages }
end
def build_chat(context, model:, messages:, tools: [])
chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) }
chat.on_end_message { |message| record_generation(chat, message, model) }
end
chat
end
def add_messages_if_needed(chat, conversation_messages)
return if conversation_messages.length == 1
conversation_messages[0...-1].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(model, messages)
{
span_name: "llm.#{event_name}",
account_id: account.id,
conversation_id: conversation&.display_id,
feature_name: event_name,
model: model,
messages: messages,
temperature: nil,
metadata: instrumentation_metadata
}
end
def instrumentation_metadata
{
channel_type: conversation&.inbox&.channel_type
}.compact
end
def conversation_messages(start_from: 0)
messages = []
character_count = start_from
conversation.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.reorder('id desc')
.each do |message|
content = message.content_for_llm
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
character_count += content.length
end
messages
end
def captain_tasks_enabled?
account.feature_enabled?('captain_tasks')
end
def api_key_configured?
api_key.present?
end
def api_key
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
end
def openai_hook
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
end
def system_api_key
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def prompt_from_file(file_name)
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
end
# Follow-up context for client-side refinement
def build_follow_up_context?
# FollowUpService should return its own updated context
!is_a?(Captain::FollowUpService)
end
def build_follow_up_context(messages, response)
{
event_name: event_name,
original_context: extract_original_context(messages),
last_response: response[:message],
conversation_history: [],
channel_type: conversation&.inbox&.channel_type
}
end
def extract_original_context(messages)
# Get the most recent user message for follow-up context
user_msg = messages.reverse.find { |m| m[:role] == 'user' }
user_msg ? user_msg[:content] : nil
end
end
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')

View File

@@ -0,0 +1,106 @@
class Captain::FollowUpService < Captain::BaseTaskService
pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
ALLOWED_EVENT_NAMES = %w[
professional
casual
friendly
confident
straightforward
fix_spelling_grammar
improve
summarize
reply_suggestion
label_suggestion
].freeze
def perform
return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
# Build context-aware system prompt
system_prompt = build_follow_up_system_prompt(follow_up_context)
# Build full message array (convert history from string keys to symbol keys)
history = follow_up_context['conversation_history'].to_a.map do |msg|
{ role: msg['role'], content: msg['content'] }
end
messages = [
{ role: 'system', content: system_prompt },
{ role: 'user', content: follow_up_context['original_context'] },
{ role: 'assistant', content: follow_up_context['last_response'] },
*history,
{ role: 'user', content: user_message }
]
response = make_api_call(model: GPT_MODEL, messages: messages)
return response if response[:error]
response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
end
private
def build_follow_up_system_prompt(session_data)
action_context = describe_previous_action(session_data['event_name'])
<<~PROMPT
You just performed a #{action_context} action for a customer support agent.
Your job now is to help them refine the result based on their feedback.
Be concise and focused on their specific request.
Output only the reply, no preamble, tags, or explanation.
PROMPT
end
def describe_previous_action(event_name)
case event_name
when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
"tone rewrite (#{event_name})"
when 'fix_spelling_grammar'
'spelling and grammar correction'
when 'improve'
'message improvement'
when 'summarize'
'conversation summary'
when 'reply_suggestion'
'reply suggestion'
when 'label_suggestion'
'label suggestion'
else
event_name
end
end
def valid_follow_up_context?
return false unless follow_up_context.is_a?(Hash)
return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
required_keys = %w[event_name original_context last_response]
required_keys.all? { |key| follow_up_context[key].present? }
end
def update_follow_up_context(user_msg, assistant_msg)
updated_history = follow_up_context['conversation_history'].to_a + [
{ 'role' => 'user', 'content' => user_msg },
{ 'role' => 'assistant', 'content' => assistant_msg }
]
{
'event_name' => follow_up_context['event_name'],
'original_context' => follow_up_context['original_context'],
'last_response' => assistant_msg,
'conversation_history' => updated_history,
'channel_type' => follow_up_context['channel_type']
}
end
def instrumentation_metadata
{
channel_type: conversation&.inbox&.channel_type || follow_up_context['channel_type']
}.compact
end
def event_name
'follow_up'
end
end

View File

@@ -0,0 +1,93 @@
class Captain::LabelSuggestionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
# Check cache first
cached_response = read_from_cache
return cached_response if cached_response.present?
# Build content
content = labels_with_messages
return nil if content.blank?
# Make API call
response = make_api_call(
model: GPT_MODEL, # TODO: Use separate model for label suggestion
messages: [
{ role: 'system', content: prompt_from_file('label_suggestion') },
{ role: 'user', content: content }
]
)
return response if response[:error].present?
# Clean up response
result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
# Cache successful result
write_to_cache(result)
result
end
private
def cache_key
return nil unless conversation
format(
::Redis::Alfred::OPENAI_CONVERSATION_KEY,
event_name: 'label_suggestion',
conversation_id: conversation.id,
updated_at: conversation.last_activity_at.to_i
)
end
def read_from_cache
return nil unless cache_key
cached = Redis::Alfred.get(cache_key)
JSON.parse(cached, symbolize_names: true) if cached.present?
rescue JSON::ParserError
nil
end
def write_to_cache(response)
Redis::Alfred.setex(cache_key, response.to_json) if cache_key
end
def labels_with_messages
return nil unless valid_conversation?(conversation)
labels = account.labels.pluck(:title).join(', ')
messages = format_messages_as_string(start_from: labels.length)
return nil if messages.blank? || labels.blank?
"Messages:\n#{messages}\nLabels:\n#{labels}"
end
def format_messages_as_string(start_from: 0)
messages = conversation_messages(start_from: start_from)
messages.map do |msg|
sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent'
"#{sender_type}: #{msg[:content]}\n"
end.join
end
def valid_conversation?(conversation)
return false if conversation.nil?
return false if conversation.messages.incoming.count < 3
return false if conversation.messages.count > 100
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
true
end
def event_name
'label_suggestion'
end
def build_follow_up_context?
false
end
end

View File

@@ -0,0 +1,42 @@
class Captain::ReplySuggestionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!, :user!]
def perform
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: formatted_conversation }
]
)
end
private
def system_prompt
template = prompt_from_file('reply')
render_liquid_template(template, prompt_variables)
end
def prompt_variables
{
'channel_type' => conversation.inbox.channel_type,
'agent_name' => user.name,
'agent_signature' => user.message_signature.presence
}
end
def render_liquid_template(template_content, variables = {})
Liquid::Template.parse(template_content).render(variables)
end
def formatted_conversation
LlmFormatter::ConversationLlmFormatter.new(conversation).format(token_limit: TOKEN_LIMIT)
end
def event_name
'reply_suggestion'
end
end
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')

View File

@@ -0,0 +1,59 @@
class Captain::RewriteService < Captain::BaseTaskService
pattr_initialize [:account!, :content!, :operation!, { conversation_display_id: nil }]
TONE_OPERATIONS = %i[casual professional friendly confident straightforward].freeze
ALLOWED_OPERATIONS = (%i[fix_spelling_grammar improve] + TONE_OPERATIONS).freeze
def perform
operation_sym = operation.to_sym
raise ArgumentError, "Invalid operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation_sym)
send(operation_sym)
end
TONE_OPERATIONS.each do |tone|
define_method(tone) do
call_llm_with_prompt(tone_rewrite_prompt(tone.to_s))
end
end
private
def fix_spelling_grammar
call_llm_with_prompt(prompt_from_file('fix_spelling_grammar'))
end
def improve
template = prompt_from_file('improve')
system_prompt = render_liquid_template(template, {
'conversation_context' => conversation.to_llm_text(include_contact_details: true),
'draft_message' => content
})
call_llm_with_prompt(system_prompt, content)
end
def call_llm_with_prompt(system_content, user_content = content)
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: system_content },
{ role: 'user', content: user_content }
]
)
end
def render_liquid_template(template_content, variables = {})
Liquid::Template.parse(template_content).render(variables)
end
def tone_rewrite_prompt(tone)
template = prompt_from_file('tone_rewrite')
render_liquid_template(template, 'tone' => tone)
end
def event_name
operation
end
end

View File

@@ -0,0 +1,19 @@
class Captain::SummaryService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
]
)
end
private
def event_name
'summarize'
end
end

View File

@@ -0,0 +1,60 @@
module Captain::ToolInstrumentation
extend ActiveSupport::Concern
include Integrations::LlmInstrumentationConstants
private
# Custom instrumentation for tool flows - outputs just the message (not full hash)
def instrument_tool_session(params)
return yield unless ChatwootApp.otel_enabled?
response = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
set_tool_session_attributes(span, params)
response = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
end
response
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception
executed ? response : yield
end
def set_tool_session_attributes(span, params)
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
end
def set_tool_session_error_attributes(span, response)
error = response[:error] || response['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
def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span|
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
end
rescue StandardError => e
Rails.logger.warn "Failed to record generation: #{e.message}"
end
def format_chat_messages(chat)
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
end
end

View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true
require 'pathname'
module ChatwootApp
def self.root
Pathname.new(File.expand_path('..', __dir__))
end
def self.max_limit
100_000
end
def self.enterprise?
return if ENV.fetch('DISABLE_ENTERPRISE', false)
@enterprise ||= root.join('enterprise').exist?
end
def self.chatwoot_cloud?
enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
end
def self.self_hosted_enterprise?
enterprise? && !chatwoot_cloud? && GlobalConfig.get_value('INSTALLATION_PRICING_PLAN') == 'enterprise'
end
def self.custom?
@custom ||= root.join('custom').exist?
end
def self.help_center_root
ENV.fetch('HELPCENTER_URL', nil) || ENV.fetch('FRONTEND_URL', nil)
end
def self.extensions
if custom?
%w[enterprise custom]
elsif enterprise?
%w[enterprise]
else
%w[]
end
end
def self.advanced_search_allowed?
enterprise? && ENV.fetch('OPENSEARCH_URL', nil).present?
end
def self.otel_enabled?
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
otel_provider.present? && secret_key.present? && otel_provider == 'langfuse'
end
end

View File

@@ -0,0 +1,25 @@
class ChatwootCaptcha
def initialize(client_response)
@client_response = client_response
@server_key = GlobalConfigService.load('HCAPTCHA_SERVER_KEY', '')
end
def valid?
return true if @server_key.blank?
return false if @client_response.blank?
validate_client_response?
end
def validate_client_response?
response = HTTParty.post('https://hcaptcha.com/siteverify',
body: {
response: @client_response,
secret: @server_key
})
return unless response.success?
response.parsed_response['success']
end
end

View File

@@ -0,0 +1,32 @@
###############
# One library to capture_exception and send to the specific service.
# # e as exception, u for user and a for account (user and account are optional)
# Usage: ChatwootExceptionTracker(e, user: u, account: a).capture_exception
############
class ChatwootExceptionTracker
def initialize(exception, user: nil, account: nil)
@exception = exception
@user = user
@account = account
end
def capture_exception
capture_exception_with_sentry if ENV['SENTRY_DSN'].present?
Rails.logger.error @exception
end
private
def capture_exception_with_sentry
Sentry.with_scope do |scope|
if @account.present?
scope.set_context('account', { id: @account.id, name: @account.name })
scope.set_tags(account_id: @account.id)
end
scope.set_user(id: @user.id, email: @user.email) if @user.is_a?(User)
Sentry.capture_exception(@exception)
end
end
end

View File

@@ -0,0 +1,120 @@
# TODO: lets use HTTParty instead of RestClient
class ChatwootHub
BASE_URL = ENV.fetch('CHATWOOT_HUB_URL', 'https://hub.2.chatwoot.com')
PING_URL = "#{BASE_URL}/ping".freeze
REGISTRATION_URL = "#{BASE_URL}/instances".freeze
PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze
EVENTS_URL = "#{BASE_URL}/events".freeze
BILLING_URL = "#{BASE_URL}/billing".freeze
CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".freeze
def self.installation_identifier
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
identifier ||= InstallationConfig.create!(name: 'INSTALLATION_IDENTIFIER', value: SecureRandom.uuid).value
identifier
end
def self.billing_url
"#{BILLING_URL}?installation_identifier=#{installation_identifier}"
end
def self.pricing_plan
return 'community' unless ChatwootApp.enterprise?
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value || 'community'
end
def self.pricing_plan_quantity
return 0 unless ChatwootApp.enterprise?
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')&.value || 0
end
def self.support_config
{
support_website_token: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN')&.value,
support_script_url: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_SCRIPT_URL')&.value,
support_identifier_hash: InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH')&.value
}
end
def self.instance_config
{
installation_identifier: installation_identifier,
installation_version: Chatwoot.config[:version],
installation_host: URI.parse(ENV.fetch('FRONTEND_URL', '')).host,
installation_env: ENV.fetch('INSTALLATION_ENV', ''),
edition: ENV.fetch('CW_EDITION', '')
}
end
def self.instance_metrics
{
accounts_count: fetch_count(Account),
users_count: fetch_count(User),
inboxes_count: fetch_count(Inbox),
conversations_count: fetch_count(Conversation),
incoming_messages_count: fetch_count(Message.incoming),
outgoing_messages_count: fetch_count(Message.outgoing),
additional_information: {}
}
end
def self.fetch_count(model)
model.last&.id || 0
end
def self.sync_with_hub
begin
info = instance_config
info = info.merge(instance_metrics) unless ENV['DISABLE_TELEMETRY']
response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json })
parsed_response = JSON.parse(response)
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
parsed_response
end
def self.register_instance(company_name, owner_name, owner_email)
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def self.send_push(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def self.get_captain_settings(account)
info = {
installation_identifier: installation_identifier,
chatwoot_account_id: account.id,
account_name: account.name
}
HTTParty.post(CAPTAIN_ACCOUNTS_URL,
body: info.to_json,
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' })
end
def self.emit_event(event_name, event_data)
return if ENV['DISABLE_TELEMETRY']
info = { event_name: event_name, event_data: event_data }
RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
end

View File

@@ -0,0 +1,32 @@
class ChatwootMarkdownRenderer
def initialize(content)
@content = content
end
def render_message
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
html = markdown_renderer.render(doc)
render_as_html_safe(html)
end
def render_article
markdown_renderer = CustomMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:table])
html = markdown_renderer.render(doc)
render_as_html_safe(html)
end
def render_markdown_to_plain_text
CommonMarker.render_doc(@content, :DEFAULT).to_plaintext
end
private
def render_as_html_safe(html)
# rubocop:disable Rails/OutputSafety
html.html_safe
# rubocop:enable Rails/OutputSafety
end
end

View File

@@ -0,0 +1,91 @@
class ConfigLoader
DEFAULT_OPTIONS = {
config_path: nil,
reconcile_only_new: true
}.freeze
def process(options = {})
options = DEFAULT_OPTIONS.merge(options)
# function of the "reconcile_only_new" flag
# if true,
# it leaves the existing config and feature flags as it is and
# creates the missing configs and feature flags with their default values
# if false,
# then it overwrites existing config and feature flags with default values
# also creates the missing configs and feature flags with their default values
@reconcile_only_new = options[:reconcile_only_new]
# setting the config path
@config_path = options[:config_path].presence
@config_path ||= Rails.root.join('config')
# general installation configs
reconcile_general_config
# default account based feature configs
reconcile_feature_config
end
def general_configs
@config_path ||= Rails.root.join('config')
@general_configs ||= YAML.safe_load(File.read("#{@config_path}/installation_config.yml")).freeze
end
private
def account_features
@account_features ||= YAML.safe_load(File.read("#{@config_path}/features.yml")).freeze
end
def reconcile_general_config
general_configs.each do |config|
new_config = config.with_indifferent_access
existing_config = InstallationConfig.find_by(name: new_config[:name])
save_general_config(existing_config, new_config)
end
end
def save_general_config(existing, latest)
if existing
# save config only if reconcile flag is false and existing configs value does not match default value
save_as_new_config(latest) if !@reconcile_only_new && compare_values(existing, latest)
else
save_as_new_config(latest)
end
end
def compare_values(existing, latest)
existing.value != latest[:value] ||
(!latest[:locked].nil? && existing.locked != latest[:locked])
end
def save_as_new_config(latest)
config = InstallationConfig.find_or_initialize_by(name: latest[:name])
config.value = latest[:value]
config.locked = latest[:locked]
config.save!
end
def reconcile_feature_config
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
if config
return false if config.value.to_s == account_features.to_s
compare_and_save_feature(config)
else
save_as_new_config({ name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS', value: account_features, locked: true })
end
end
def compare_and_save_feature(config)
features = if @reconcile_only_new
# leave the existing feature flag values as it is and add new feature flags with default values
(config.value + account_features).uniq { |h| h['name'] }
else
# update the existing feature flag values with default values and add new feature flags with default values
(account_features + config.value).uniq { |h| h['name'] }
end
config.update({ name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS', value: features, locked: true })
end
end

View File

@@ -0,0 +1,17 @@
module Current
thread_mattr_accessor :user
thread_mattr_accessor :account
thread_mattr_accessor :account_user
thread_mattr_accessor :executed_by
thread_mattr_accessor :contact
thread_mattr_accessor :captain_resolve_reason
def self.reset
Current.user = nil
Current.account = nil
Current.account_user = nil
Current.executed_by = nil
Current.contact = nil
Current.captain_resolve_reason = nil
end
end

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
module CustomExceptions::Account
class InvalidEmail < CustomExceptions::Base
def message
if @data[:domain_blocked]
I18n.t 'errors.signup.blocked_domain'
elsif @data[:disposable]
I18n.t 'errors.signup.disposable_email'
elsif !@data[:valid]
I18n.t 'errors.signup.invalid_email'
end
end
end
class UserExists < CustomExceptions::Base
def message
I18n.t('errors.signup.email_already_exists', email: @data[:email])
end
end
class InvalidParams < CustomExceptions::Base
def message
I18n.t 'errors.signup.invalid_params'
end
end
class UserErrors < CustomExceptions::Base
def message
@data[:errors].full_messages.join(',')
end
end
class SignupFailed < CustomExceptions::Base
def message
I18n.t 'errors.signup.failed'
end
end
class PlanUpgradeRequired < CustomExceptions::Base
def message
I18n.t 'errors.plan_upgrade_required.failed'
end
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class CustomExceptions::Base < StandardError
def to_hash
{
message: message
}
end
def http_status
403
end
def initialize(data)
@data = data
super()
end
end

View File

@@ -0,0 +1,25 @@
module CustomExceptions::CustomFilter
class InvalidAttribute < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_attribute', key: @data[:key], allowed_keys: @data[:allowed_keys].join(','))
end
end
class InvalidOperator < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_operator', attribute_name: @data[:attribute_name], allowed_keys: @data[:allowed_keys].join(','))
end
end
class InvalidQueryOperator < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_query_operator')
end
end
class InvalidValue < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
end
end
end

View File

@@ -0,0 +1,19 @@
module CustomExceptions::Pdf
class UploadError < CustomExceptions::Base
def initialize(message = 'PDF upload failed')
super(message)
end
end
class ValidationError < CustomExceptions::Base
def initialize(message = 'PDF validation failed')
super(message)
end
end
class FaqGenerationError < CustomExceptions::Base
def initialize(message = 'PDF FAQ generation failed')
super(message)
end
end
end

View File

@@ -0,0 +1,90 @@
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
CONFIG_PATH = Rails.root.join('config/markdown_embeds.yml')
def self.config
@config ||= YAML.load_file(CONFIG_PATH)
end
def self.embed_regexes
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
end
def text(node)
content = node.string_content
if content.include?('^')
split_content = parse_sup(content)
out(split_content.join)
else
out(escape_html(content))
end
end
def link(node)
return if surrounded_by_empty_lines?(node) && render_embedded_content(node)
# If it's not a supported embed link, render normally
super
end
private
def surrounded_by_empty_lines?(node)
prev_node_empty?(node.previous) && next_node_empty?(node.next)
end
def prev_node_empty?(prev_node)
prev_node.nil? || node_empty?(prev_node)
end
def next_node_empty?(next_node)
next_node.nil? || node_empty?(next_node)
end
def node_empty?(node)
(node.type == :text && node.string_content.strip.empty?) || (node.type != :text)
end
def render_embedded_content(node)
link_url = node.url
embed_html = find_matching_embed(link_url)
return false unless embed_html
out(embed_html)
true
end
def find_matching_embed(link_url)
self.class.embed_regexes.each do |embed_key, regex|
match = link_url.match(regex)
next unless match
return render_embed_from_match(embed_key, match)
end
nil
end
def render_embed_from_match(embed_key, match_data)
embed_config = self.class.config[embed_key]
return nil unless embed_config
template = embed_config['template']
# Use Ruby's built-in named captures with gsub to handle CSS % values
match_data.named_captures.each do |var_name, value|
template = template.gsub("%{#{var_name}}", value)
end
template
end
def parse_sup(content)
content.split(/(\^[^\^]+\^)/).map do |segment|
if segment.start_with?('^') && segment.end_with?('^')
"<sup>#{escape_html(segment[1..-2])}</sup>"
else
escape_html(segment)
end
end
end
end

View File

@@ -0,0 +1,52 @@
class Dyte
BASE_URL = 'https://api.dyte.io/v2'.freeze
API_KEY_HEADER = 'Authorization'.freeze
PRESET_NAME = 'group_call_host'.freeze
def initialize(organization_id, api_key)
@api_key = Base64.strict_encode64("#{organization_id}:#{api_key}")
@organization_id = organization_id
raise ArgumentError, 'Missing Credentials' if @api_key.blank? || @organization_id.blank?
end
def create_a_meeting(title)
payload = {
'title': title
}
path = 'meetings'
response = post(path, payload)
process_response(response)
end
def add_participant_to_meeting(meeting_id, client_id, name, avatar_url)
raise ArgumentError, 'Missing information' if meeting_id.blank? || client_id.blank? || name.blank? || avatar_url.blank?
payload = {
'custom_participant_id': client_id.to_s,
'name': name,
'picture': avatar_url,
'preset_name': PRESET_NAME
}
path = "meetings/#{meeting_id}/participants"
response = post(path, payload)
process_response(response)
end
private
def process_response(response)
return response.parsed_response['data'].with_indifferent_access if response.success?
{ error: response.parsed_response, error_code: response.code }
end
def post(path, payload)
HTTParty.post(
"#{BASE_URL}/#{path}", {
headers: { API_KEY_HEADER => "Basic #{@api_key}", 'Content-Type' => 'application/json' },
body: payload.to_json
}
)
end
end

View File

@@ -0,0 +1,14 @@
class Events::Base
attr_accessor :data
attr_reader :name, :timestamp
def initialize(name, timestamp, data)
@name = name
@data = data
@timestamp = timestamp
end
def method_name
name.to_s.tr('.', '_')
end
end

View File

@@ -0,0 +1,60 @@
# frozen_string_literal: true
module Events::Types
### Installation Events ###
# account events
ACCOUNT_CREATED = 'account.created'
ACCOUNT_CACHE_INVALIDATED = 'account.cache_invalidated'
#### Account Events ###
# campaign events
CAMPAIGN_TRIGGERED = 'campaign.triggered'
# channel events
WEBWIDGET_TRIGGERED = 'webwidget.triggered'
# conversation events
CONVERSATION_CREATED = 'conversation.created'
CONVERSATION_UPDATED = 'conversation.updated'
CONVERSATION_READ = 'conversation.read'
CONVERSATION_BOT_HANDOFF = 'conversation.bot_handoff'
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
CONVERSATION_OPENED = 'conversation.opened'
CONVERSATION_RESOLVED = 'conversation.resolved'
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
ASSIGNEE_CHANGED = 'assignee.changed'
TEAM_CHANGED = 'team.changed'
CONVERSATION_TYPING_ON = 'conversation.typing_on'
CONVERSATION_TYPING_OFF = 'conversation.typing_off'
CONVERSATION_MENTIONED = 'conversation.mentioned'
# message events
MESSAGE_CREATED = 'message.created'
FIRST_REPLY_CREATED = 'first.reply.created'
REPLY_CREATED = 'reply.created'
MESSAGE_UPDATED = 'message.updated'
# contact events
CONTACT_CREATED = 'contact.created'
CONTACT_UPDATED = 'contact.updated'
CONTACT_MERGED = 'contact.merged'
CONTACT_DELETED = 'contact.deleted'
# contact events
INBOX_CREATED = 'inbox.created'
INBOX_UPDATED = 'inbox.updated'
# notification events
NOTIFICATION_CREATED = 'notification.created'
NOTIFICATION_DELETED = 'notification.deleted'
NOTIFICATION_UPDATED = 'notification.updated'
# agent events
AGENT_ADDED = 'agent.added'
AGENT_REMOVED = 'agent.removed'
# copilot events
COPILOT_MESSAGE_CREATED = 'copilot.message.created'
end

View File

@@ -0,0 +1,19 @@
require 'net/imap'
module ExceptionList
REST_CLIENT_EXCEPTIONS = [RestClient::NotFound, RestClient::GatewayTimeout, RestClient::BadRequest,
RestClient::MethodNotAllowed, RestClient::Forbidden, RestClient::InternalServerError,
RestClient::Exceptions::OpenTimeout, RestClient::Exceptions::ReadTimeout,
RestClient::TemporaryRedirect, RestClient::SSLCertificateNotVerified, RestClient::PaymentRequired,
RestClient::BadGateway, RestClient::Unauthorized, RestClient::PayloadTooLarge,
RestClient::MovedPermanently, RestClient::ServiceUnavailable, Errno::ECONNREFUSED, SocketError].freeze
SMTP_EXCEPTIONS = [
Net::SMTPSyntaxError
].freeze
IMAP_EXCEPTIONS = [
Errno::ECONNREFUSED, Net::OpenTimeout,
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
SocketError
].freeze
end

View File

@@ -0,0 +1,226 @@
## This file contains the filter configurations which we use for the following
# 1. Conversation Filters (app/services/filter_service.rb)
# 2. Contact Filters (app/services/filter_service.rb)
# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb)
# Format
# - Parent Key (conversation, contact, messages)
# - Key (attribute_name)
# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"]
# - data_type: "text" : supported ["text", "text_case_insensitive", "number", "boolean", "labels", "date", "link"]
# - filter_operators: ["equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present", "is_greater_than", "is_less_than", "days_before", "starts_with"]
### ----- Conversation Filters ----- ###
conversations:
status:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
assignee_id:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
inbox_id:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
team_id:
attribute_type: "standard"
data_type: "number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
priority:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
display_id:
attribute_type: "standard"
data_type: "Number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
campaign_id:
attribute_type: "standard"
data_type: "Number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
labels:
attribute_type: "standard"
data_type: "labels"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
browser_language:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
conversation_language:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
referer:
attribute_type: "additional_attributes"
data_type: "link"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
created_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
last_activity_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
mail_subject:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
### ----- End of Conversation Filters ----- ###
### ----- Contact Filters ----- ###
contacts:
name:
attribute_type: "standard"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
phone_number:
attribute_type: "standard"
data_type: "text" # Text is not explicity defined in filters, default filter will be used
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
- "starts_with"
email:
attribute_type: "standard"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
identifier:
attribute_type: "standard"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
country_code:
attribute_type: "additional_attributes"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
city:
attribute_type: "additional_attributes"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
company:
attribute_type: "additional_attributes"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
labels:
attribute_type: "standard"
data_type: "labels"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
created_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
last_activity_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
blocked:
attribute_type: "standard"
data_type: "boolean"
filter_operators:
- "equal_to"
- "not_equal_to"
### ----- End of Contact Filters ----- ###
### ----- Message Filters ----- ###
messages:
message_type:
attribute_type: "standard"
data_type: "numeric"
filter_operators:
- "equal_to"
- "not_equal_to"
content:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
### ----- End of Message Filters ----- ###

View File

@@ -0,0 +1,57 @@
class GlobalConfig
VERSION = 'V1'.freeze
KEY_PREFIX = 'GLOBAL_CONFIG'.freeze
DEFAULT_EXPIRY = 1.day
class << self
def get(*args)
config_keys = *args
config = {}
config_keys.each do |config_key|
config[config_key] = load_from_cache(config_key)
end
typecast_config(config)
config.with_indifferent_access
end
def get_value(arg)
load_from_cache(arg)
end
def clear_cache
cached_keys = $alfred.with { |conn| conn.keys("#{VERSION}:#{KEY_PREFIX}:*") }
(cached_keys || []).each do |cached_key|
$alfred.with { |conn| conn.expire(cached_key, 0) }
end
end
private
def typecast_config(config)
general_configs = ConfigLoader.new.general_configs
config.each do |config_key, config_value|
config_type = general_configs.find { |c| c['name'] == config_key }&.dig('type')
config[config_key] = ActiveRecord::Type::Boolean.new.cast(config_value) if config_type == 'boolean'
end
end
def load_from_cache(config_key)
cache_key = "#{VERSION}:#{KEY_PREFIX}:#{config_key}"
cached_value = $alfred.with { |conn| conn.get(cache_key) }
if cached_value.blank?
value_from_db = db_fallback(config_key)
cached_value = { value: value_from_db }.to_json
$alfred.with { |conn| conn.set(cache_key, cached_value, { ex: DEFAULT_EXPIRY }) }
end
JSON.parse(cached_value)['value']
end
def db_fallback(config_key)
InstallationConfig.find_by(name: config_key)&.value
end
end
end

View File

@@ -0,0 +1,17 @@
class GlobalConfigService
def self.load(config_key, default_value)
config = GlobalConfig.get(config_key)[config_key]
return config if config.present?
# To support migrating existing instance relying on env variables
# TODO: deprecate this later down the line
config_value = ENV.fetch(config_key) { default_value }
return if config_value.blank?
i = InstallationConfig.where(name: config_key).first_or_create(value: config_value, locked: false)
# To clear a nil value that might have been cached in the previous call
GlobalConfig.clear_cache
i.value
end
end

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

View File

@@ -0,0 +1,17 @@
module Limits
BULK_ACTIONS_LIMIT = 100
BULK_EXTERNAL_HTTP_CALLS_LIMIT = 25
URL_LENGTH_LIMIT = 2048 # https://stackoverflow.com/questions/417142
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
COMPANY_NAME_LENGTH_LIMIT = 100
COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
MAX_CUSTOM_FILTERS_PER_USER = 1000
MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS = 90
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
end
end

View File

@@ -0,0 +1,158 @@
class Linear
BASE_URL = 'https://api.linear.app/graphql'.freeze
REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze
PRIORITY_LEVELS = (0..4).to_a
def initialize(access_token)
@access_token = access_token
raise ArgumentError, 'Missing Credentials' if access_token.blank?
end
def teams
query = {
query: Linear::Queries::TEAMS_QUERY
}
response = post(query)
process_response(response)
end
def team_entities(team_id)
raise ArgumentError, 'Missing team id' if team_id.blank?
query = {
query: Linear::Queries.team_entities_query(team_id)
}
response = post(query)
process_response(response)
end
def search_issue(term)
raise ArgumentError, 'Missing search term' if term.blank?
query = {
query: Linear::Queries.search_issue(term)
}
response = post(query)
process_response(response)
end
def linked_issues(url)
raise ArgumentError, 'Missing link' if url.blank?
query = {
query: Linear::Queries.linked_issues(url)
}
response = post(query)
process_response(response)
end
def create_issue(params, user = nil)
validate_team_and_title(params)
validate_priority(params[:priority])
validate_label_ids(params[:label_ids])
variables = build_issue_variables(params, user)
mutation = Linear::Mutations.issue_create(variables)
response = post({ query: mutation })
process_response(response)
end
def link_issue(link, issue_id, title, user = nil)
raise ArgumentError, 'Missing link' if link.blank?
raise ArgumentError, 'Missing issue id' if issue_id.blank?
link_params = build_link_params(issue_id, link, title, user)
payload = { query: Linear::Mutations.issue_link(link_params) }
response = post(payload)
process_response(response)
end
def unlink_issue(link_id)
raise ArgumentError, 'Missing link id' if link_id.blank?
payload = {
query: Linear::Mutations.unlink_issue(link_id)
}
response = post(payload)
process_response(response)
end
def revoke_token
response = HTTParty.post(
REVOKE_URL,
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
)
response.success?
end
private
def build_issue_variables(params, user)
variables = {
title: params[:title],
teamId: params[:team_id],
description: params[:description],
assigneeId: params[:assignee_id],
priority: params[:priority],
labelIds: params[:label_ids],
projectId: params[:project_id],
stateId: params[:state_id]
}.compact
# Add user attribution if available
if user&.name.present?
variables[:createAsUser] = user.name
variables[:displayIconUrl] = user.avatar_url if user.avatar_url.present?
end
variables
end
def build_link_params(issue_id, link, title, user)
params = {
issue_id: issue_id,
link: link,
title: title
}
if user.present?
params[:user_name] = user.name if user.name.present?
params[:user_avatar_url] = user.avatar_url if user.avatar_url.present?
end
params
end
def validate_team_and_title(params)
raise ArgumentError, 'Missing team id' if params[:team_id].blank?
raise ArgumentError, 'Missing title' if params[:title].blank?
end
def validate_priority(priority)
return if priority.nil? || PRIORITY_LEVELS.include?(priority)
raise ArgumentError, 'Invalid priority value. Priority must be 0, 1, 2, 3, or 4.'
end
def validate_label_ids(label_ids)
return if label_ids.nil?
return if label_ids.is_a?(Array) && label_ids.all?(String)
raise ArgumentError, 'label_ids must be an array of strings.'
end
def post(payload)
HTTParty.post(
BASE_URL,
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' },
body: payload.to_json
)
end
def process_response(response)
return response.parsed_response['data'].with_indifferent_access if response.success? && !response.parsed_response['data'].nil?
{ error: response.parsed_response, error_code: response.code }
end
end

View File

@@ -0,0 +1,66 @@
module Linear::Mutations
def self.graphql_value(value)
case value
when String
value.to_json
when Array
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
else
value.to_s
end
end
def self.graphql_input(input)
input.map { |key, value| "#{key}: #{graphql_value(value)}" }.join(', ')
end
def self.issue_create(input)
<<~GRAPHQL
mutation {
issueCreate(input: { #{graphql_input(input)} }) {
success
issue {
id
title
identifier
}
}
}
GRAPHQL
end
def self.issue_link(params)
issue_id = params[:issue_id]
link = params[:link]
title = params[:title]
user_name = params[:user_name]
user_avatar_url = params[:user_avatar_url]
user_params = []
user_params << "createAsUser: #{graphql_value(user_name)}" if user_name.present?
user_params << "displayIconUrl: #{graphql_value(user_avatar_url)}" if user_avatar_url.present?
user_params_str = user_params.any? ? ", #{user_params.join(', ')}" : ''
<<~GRAPHQL
mutation {
attachmentLinkURL(url: #{graphql_value(link)}, issueId: #{graphql_value(issue_id)}, title: #{graphql_value(title)}#{user_params_str}) {
success
attachment {
id
}
}
}
GRAPHQL
end
def self.unlink_issue(link_id)
<<~GRAPHQL
mutation {
attachmentDelete(id: "#{link_id}") {
success
}
}
GRAPHQL
end
end

View File

@@ -0,0 +1,104 @@
module Linear::Queries
TEAMS_QUERY = <<~GRAPHQL.freeze
query {
teams {
nodes {
id
name
}
}
}
GRAPHQL
def self.team_entities_query(team_id)
<<~GRAPHQL
query {
users {
nodes {
id
name
}
}
projects {
nodes {
id
name
}
}
workflowStates(
filter: { team: { id: { eq: "#{team_id}" } } }
) {
nodes {
id
name
}
}
issueLabels(
filter: { team: { id: { eq: "#{team_id}" } } }
) {
nodes {
id
name
}
}
}
GRAPHQL
end
def self.search_issue(term)
<<~GRAPHQL
query {
searchIssues(term: #{Linear::Mutations.graphql_value(term)}) {
nodes {
id
title
description
identifier
state {
name
color
}
}
}
}
GRAPHQL
end
def self.linked_issues(url)
<<~GRAPHQL
query {
attachmentsForURL(url: "#{url}") {
nodes {
id
title
issue {
id
identifier
title
description
priority
createdAt
url
assignee {
name
avatarUrl
}
state {
name
color
}
labels {
nodes{
id
name
color
description
}
}
}
}
}
}
GRAPHQL
end
end

View File

@@ -0,0 +1,49 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
class << self
def initialized?
@initialized ||= false
end
def initialize!
return if @initialized
configure_ruby_llm
@initialized = true
end
def reset!
@initialized = false
end
def with_api_key(api_key, api_base: nil)
context = RubyLLM.context do |config|
config.openai_api_key = api_key
config.openai_api_base = api_base
end
yield context
end
private
def configure_ruby_llm
RubyLLM.configure do |config|
config.openai_api_key = system_api_key if system_api_key.present?
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
config.logger = Rails.logger
end
end
def system_api_key
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def openai_endpoint
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
end
end
end

View File

@@ -0,0 +1,41 @@
module Llm::Models
CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze
class << self
def providers = CONFIG['providers']
def models = CONFIG['models']
def features = CONFIG['features']
def feature_keys = CONFIG['features'].keys
def default_model_for(feature)
CONFIG.dig('features', feature.to_s, 'default')
end
def models_for(feature)
CONFIG.dig('features', feature.to_s, 'models') || []
end
def valid_model_for?(feature, model_name)
models_for(feature).include?(model_name.to_s)
end
def feature_config(feature_key)
feature = features[feature_key.to_s]
return nil unless feature
{
models: feature['models'].map do |model_name|
model = models[model_name]
{
id: model_name,
display_name: model['display_name'],
provider: model['provider'],
coming_soon: model['coming_soon'],
credit_multiplier: model['credit_multiplier']
}
end,
default: feature['default']
}
end
end
end

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
OPENAI_API_ENDPOINT = 'https://api.openai.com'
PROVIDER_PREFIXES = {
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
'anthropic' => %w[claude-],
'google' => %w[gemini-],
'mistral' => %w[mistral- codestral-],
'deepseek' => %w[deepseek-]
}.freeze
end

View File

@@ -0,0 +1,55 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# frozen_string_literal: true
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp
require 'omniauth-oauth2'
# Implements an OmniAuth strategy to get a Microsoft Graph
# compatible token from Azure AD
class MicrosoftGraphAuth < OmniAuth::Strategies::OAuth2
option :name, :microsoft_graph_auth
DEFAULT_SCOPE = 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send'
# Configure the Microsoft identity platform endpoints
option :client_options,
site: 'https://login.microsoftonline.com',
authorize_url: '/common/oauth2/v2.0/authorize',
token_url: '/common/oauth2/v2.0/token'
option :pcke, true
# Send the scope parameter during authorize
option :authorize_options, [:scope]
# Unique ID for the user is the id field
uid { raw_info['id'] }
# Get additional information after token is retrieved
extra do
{
'raw_info' => raw_info
}
end
def raw_info
# Get user profile information from the /me endpoint
@raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me?$select=displayName').parsed
end
def authorize_params
super.tap do |params|
params[:scope] = request.params['scope'] if request.params['scope']
params[:scope] ||= DEFAULT_SCOPE
end
end
# Override callback URL
# OmniAuth by default passes the entire URL of the callback, including
# query parameters. Azure fails validation because that doesn't match the
# registered callback.
def callback_url
ENV.fetch('FRONTEND_URL', nil) + app_path
end
end

View File

@@ -0,0 +1,77 @@
class OnlineStatusTracker
# NOTE: You can customise the environment variable to keep your agents/contacts as online for longer
PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds
# presence : sorted set with timestamp as the score & object id as value
# obj_type: Contact | User
def self.update_presence(account_id, obj_type, obj_id)
::Redis::Alfred.zadd(presence_key(account_id, obj_type), Time.now.to_i, obj_id)
end
def self.get_presence(account_id, obj_type, obj_id)
connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id)
connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i
end
def self.presence_key(account_id, type)
case type
when 'Contact'
format(::Redis::Alfred::ONLINE_PRESENCE_CONTACTS, account_id: account_id)
else
format(::Redis::Alfred::ONLINE_PRESENCE_USERS, account_id: account_id)
end
end
# online status : online | busy | offline
# redis hash with obj_id key && status as value
def self.set_status(account_id, user_id, status)
::Redis::Alfred.hset(status_key(account_id), user_id, status)
end
def self.get_status(account_id, user_id)
::Redis::Alfred.hget(status_key(account_id), user_id)
end
def self.status_key(account_id)
format(::Redis::Alfred::ONLINE_STATUS, account_id: account_id)
end
def self.get_available_contact_ids(account_id)
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
# exclusive minimum score is specified by prefixing (
# we are clearing old records because this could clogg up the sorted set
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
::Redis::Alfred.zrangebyscore(presence_key(account_id, 'Contact'), range_start, '+inf')
end
def self.get_available_contacts(account_id)
# returns {id1: 'online', id2: 'online'}
get_available_contact_ids(account_id).index_with { |_id| 'online' }
end
def self.get_available_users(account_id)
user_ids = get_available_user_ids(account_id)
return {} if user_ids.blank?
user_availabilities = ::Redis::Alfred.hmget(status_key(account_id), user_ids)
user_ids.map.with_index { |id, index| [id, (user_availabilities[index] || get_availability_from_db(account_id, id))] }.to_h
end
def self.get_availability_from_db(account_id, user_id)
availability = Account.find(account_id).account_users.find_by(user_id: user_id).availability
set_status(account_id, user_id, availability)
availability
end
def self.get_available_user_ids(account_id)
account = Account.find(account_id)
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
user_ids = ::Redis::Alfred.zrangebyscore(presence_key(account_id, 'User'), range_start, '+inf')
# since we are dealing with redis items as string, casting to string
user_ids += account.account_users.where(auto_offline: false)&.map(&:user_id)&.map(&:to_s)
user_ids.uniq
end
end

View File

@@ -0,0 +1,91 @@
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'base64'
module OpentelemetryConfig
class << self
def tracer
initialize! unless initialized?
OpenTelemetry.tracer_provider.tracer('chatwoot')
end
def initialized?
@initialized ||= false
end
def initialize!
return if @initialized
return mark_initialized unless langfuse_provider?
return mark_initialized unless langfuse_credentials_present?
configure_opentelemetry
mark_initialized
rescue StandardError => e
Rails.logger.error "Failed to configure OpenTelemetry: #{e.message}"
mark_initialized
end
def reset!
@initialized = false
end
private
def mark_initialized
@initialized = true
end
def langfuse_provider?
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
otel_provider == 'langfuse'
end
def langfuse_credentials_present?
endpoint = InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value
public_key = InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
if endpoint.blank? || public_key.blank? || secret_key.blank?
Rails.logger.error 'OpenTelemetry disabled (LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY is missing)'
return false
end
true
end
def langfuse_credentials
{
endpoint: InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value,
public_key: InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value,
secret_key: InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
}
end
def traces_endpoint
credentials = langfuse_credentials
"#{credentials[:endpoint]}/api/public/otel/v1/traces"
end
def exporter_config
credentials = langfuse_credentials
auth_header = Base64.strict_encode64("#{credentials[:public_key]}:#{credentials[:secret_key]}")
config = {
endpoint: traces_endpoint,
headers: { 'Authorization' => "Basic #{auth_header}" }
}
config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
config
end
def configure_opentelemetry
OpenTelemetry::SDK.configure do |c|
c.service_name = 'chatwoot'
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(**exporter_config)
c.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
Rails.logger.info 'OpenTelemetry initialized and configured to export to Langfuse'
end
end
end
end

View File

@@ -0,0 +1,143 @@
# refer : https://redis.io/commands
module Redis::Alfred
include Redis::RedisKeys
class << self
# key operations
# set a value in redis
def set(key, value, nx: false, ex: false) # rubocop:disable Naming/MethodParameterName
$alfred.with { |conn| conn.set(key, value, nx: nx, ex: ex) }
end
# set a key with expiry period
# TODO: Deprecate this method, use set with ex: 1.day instead
def setex(key, value, expiry = 1.day)
$alfred.with { |conn| conn.setex(key, expiry, value) }
end
def get(key)
$alfred.with { |conn| conn.get(key) }
end
def delete(key)
$alfred.with { |conn| conn.del(key) }
end
# increment a key by 1. throws error if key value is incompatible
# sets key to 0 before operation if key doesn't exist
def incr(key)
$alfred.with { |conn| conn.incr(key) }
end
def exists?(key)
$alfred.with { |conn| conn.exists?(key) }
end
# set expiry on a key in seconds
def expire(key, seconds)
$alfred.with { |conn| conn.expire(key, seconds) }
end
# scan keys matching a pattern
def scan_each(match: nil, count: 100, &)
$alfred.with do |conn|
conn.scan_each(match: match, count: count, &)
end
end
# count keys matching a pattern
def keys_count(pattern)
count = 0
scan_each(match: pattern) { count += 1 }
count
end
# list operations
def llen(key)
$alfred.with { |conn| conn.llen(key) }
end
def lrange(key, start_index = 0, end_index = -1)
$alfred.with { |conn| conn.lrange(key, start_index, end_index) }
end
def rpop(key)
$alfred.with { |conn| conn.rpop(key) }
end
def lpush(key, values)
$alfred.with { |conn| conn.lpush(key, values) }
end
def rpoplpush(source, destination)
$alfred.with { |conn| conn.rpoplpush(source, destination) }
end
def lrem(key, value, count = 0)
$alfred.with { |conn| conn.lrem(key, count, value) }
end
# hash operations
# add a key value to redis hash
def hset(key, field, value)
$alfred.with { |conn| conn.hset(key, field, value) }
end
# get value from redis hash
def hget(key, field)
$alfred.with { |conn| conn.hget(key, field) }
end
# get values of multiple keys from redis hash
def hmget(key, fields)
$alfred.with { |conn| conn.hmget(key, *fields) }
end
# sorted set operations
# add score and value for a key
# Modern Redis syntax: zadd(key, [[score, member], ...])
def zadd(key, score, value = nil)
if value.nil? && score.is_a?(Array)
# New syntax: score is actually an array of [score, member] pairs
$alfred.with { |conn| conn.zadd(key, score) }
else
# Support old syntax for backward compatibility
$alfred.with { |conn| conn.zadd(key, [[score, value]]) }
end
end
# get score of a value for key
def zscore(key, value)
$alfred.with { |conn| conn.zscore(key, value) }
end
# count members in a sorted set with scores within the given range
def zcount(key, min_score, max_score)
$alfred.with { |conn| conn.zcount(key, min_score, max_score) }
end
# get the number of members in a sorted set
def zcard(key)
$alfred.with { |conn| conn.zcard(key) }
end
# get values by score
def zrangebyscore(key, range_start, range_end, with_scores: false, limit: nil)
options = {}
options[:with_scores] = with_scores if with_scores
options[:limit] = limit if limit
$alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end, **options) }
end
# remove values by score
# exclusive score is specified by prefixing (
def zremrangebyscore(key, range_start, range_end)
$alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) }
end
end
end

View File

@@ -0,0 +1,49 @@
module Redis::Config
DEFAULT_SENTINEL_PORT ||= '26379'.freeze
class << self
def app
config
end
def config
@config ||= sentinel? ? sentinel_config : base_config
end
def base_config
{
url: ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379'),
password: ENV.fetch('REDIS_PASSWORD', nil).presence,
ssl_params: { verify_mode: Chatwoot.redis_ssl_verify_mode },
reconnect_attempts: 2,
timeout: 1
}
end
def sentinel?
ENV.fetch('REDIS_SENTINELS', nil).presence
end
def sentinel_url_config(sentinel_url)
host, port = sentinel_url.split(':').map(&:strip)
sentinel_url_config = { host: host, port: port || DEFAULT_SENTINEL_PORT }
password = ENV.fetch('REDIS_SENTINEL_PASSWORD', base_config[:password])
sentinel_url_config[:password] = password if password.present?
sentinel_url_config
end
def sentinel_config
redis_sentinels = ENV.fetch('REDIS_SENTINELS', nil)
# expected format for REDIS_SENTINELS url string is host1:port1, host2:port2
sentinels = redis_sentinels.split(',').map do |sentinel_url|
sentinel_url_config(sentinel_url)
end
# over-write redis url as redis://:<your-redis-password>@<master-name>/ when using sentinel
# more at https://github.com/redis/redis-rb/issues/531#issuecomment-263501322
master = "redis://#{ENV.fetch('REDIS_SENTINEL_MASTER_NAME', 'mymaster')}"
base_config.merge({ url: master, sentinels: sentinels })
end
end
end

View File

@@ -0,0 +1,63 @@
# Redis::LockManager provides a simple mechanism to handle distributed locks using Redis.
# This class ensures that only one instance of a given operation runs at a given time across all processes/nodes.
# It uses the $alfred Redis namespace for all its operations.
#
# Example Usage:
#
# lock_manager = Redis::LockManager.new
#
# if lock_manager.lock("some_key")
# # Critical code that should not be run concurrently
# lock_manager.unlock("some_key")
# end
#
class Redis::LockManager
# Default lock timeout set to 1 second. This means that if the lock isn't released
# within 1 second, it will automatically expire.
# This helps to avoid deadlocks in case the process holding the lock crashes or fails to release it.
LOCK_TIMEOUT = 1.second
# Attempts to acquire a lock for the given key.
#
# If the lock is successfully acquired, the method returns true. If the key is
# already locked or if any other error occurs, it returns false.
#
# === Parameters
# * +key+ - The key for which the lock is to be acquired.
# * +timeout+ - Duration in seconds for which the lock is valid. Defaults to +LOCK_TIMEOUT+.
#
# === Returns
# * +true+ if the lock was successfully acquired.
# * +false+ if the lock was not acquired.
def lock(key, timeout = LOCK_TIMEOUT)
value = Time.now.to_f.to_s
# nx: true means set the key only if it does not exist
Redis::Alfred.set(key, value, nx: true, ex: timeout) ? true : false
end
# Releases a lock for the given key.
#
# === Parameters
# * +key+ - The key for which the lock is to be released.
#
# === Returns
# * +true+ indicating the lock release operation was initiated.
#
# Note: If the key wasn't locked, this operation will have no effect.
def unlock(key)
Redis::Alfred.delete(key)
true
end
# Checks if the given key is currently locked.
#
# === Parameters
# * +key+ - The key to check.
#
# === Returns
# * +true+ if the key is locked.
# * +false+ otherwise.
def locked?(key)
Redis::Alfred.exists?(key)
end
end

View File

@@ -0,0 +1,55 @@
module Redis::RedisKeys
## Inbox Keys
# Array storing the ordered ids for agent round robin assignment
ROUND_ROBIN_AGENTS = 'ROUND_ROBIN_AGENTS:%<inbox_id>d'.freeze
## Conversation keys
# Detect whether to send an email reply to the conversation
CONVERSATION_MAILER_KEY = 'CONVERSATION::%<conversation_id>d'.freeze
# Whether a conversation is muted ?
CONVERSATION_MUTE_KEY = 'CONVERSATION::%<id>d::MUTED'.freeze
CONVERSATION_DRAFT_MESSAGE = 'CONVERSATION::%<id>d::DRAFT_MESSAGE'.freeze
## User Keys
# SSO Auth Tokens
USER_SSO_AUTH_TOKEN = 'USER_SSO_AUTH_TOKEN::%<user_id>d::%<token>s'.freeze
## Online Status Keys
# hash containing user_id key and status as value
ONLINE_STATUS = 'ONLINE_STATUS::%<account_id>d'.freeze
# sorted set storing online presense of account contacts
ONLINE_PRESENCE_CONTACTS = 'ONLINE_PRESENCE::%<account_id>d::CONTACTS'.freeze
# sorted set storing online presense of account users
ONLINE_PRESENCE_USERS = 'ONLINE_PRESENCE::%<account_id>d::USERS'.freeze
## Authorization Status Keys
# Used to track token expiry and such issues for facebook slack integrations etc
AUTHORIZATION_ERROR_COUNT = 'AUTHORIZATION_ERROR_COUNT:%<obj_type>s:%<obj_id>d'.freeze
REAUTHORIZATION_REQUIRED = 'REAUTHORIZATION_REQUIRED:%<obj_type>s:%<obj_id>d'.freeze
## Internal Installation related keys
CHATWOOT_INSTALLATION_ONBOARDING = 'CHATWOOT_INSTALLATION_ONBOARDING'.freeze
CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING = 'CHATWOOT_CONFIG_RESET_WARNING'.freeze
LATEST_CHATWOOT_VERSION = 'LATEST_CHATWOOT_VERSION'.freeze
# Check if a message create with same source-id is in progress?
MESSAGE_SOURCE_KEY = 'MESSAGE_SOURCE_KEY::%<id>s'.freeze
OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::V1::%<event_name>s::%<conversation_id>d::%<updated_at>d'.freeze
## Sempahores / Locks
# We don't want to process messages from the same sender concurrently to prevent creating double conversations
FACEBOOK_MESSAGE_MUTEX = 'FB_MESSAGE_CREATE_LOCK::%<sender_id>s::%<recipient_id>s'.freeze
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
TIKTOK_MESSAGE_MUTEX = 'TIKTOK_MESSAGE_CREATE_LOCK::%<business_id>s::%<conversation_id>s'.freeze
TIKTOK_REFRESH_TOKEN_MUTEX = 'TIKTOK_REFRESH_TOKEN_LOCK::%<channel_id>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
## Auto Assignment Keys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
end

View File

@@ -0,0 +1,19 @@
module RegexHelper
# user https://rubular.com/ to quickly validate your regex
# the following regext needs atleast one character which should be
# valid unicode letter, unicode number, underscore, hyphen
# shouldn't start with a underscore or hyphen
UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE = Regexp.new('\A[\p{L}\p{N}]+[\p{L}\p{N}_-]+\Z')
# Regex to match mention markdown links and extract display names
# Matches: [@display name](mention://user|team/id/url_encoded_name)
# Captures: 1) @display name (including emojis), 2) url_encoded_name
# Uses [^]]+ to match any characters except ] in display name to support emojis
# NOTE: Still used by Slack integration (lib/integrations/slack/send_on_slack_service.rb)
# while notifications use CommonMarker for better markdown processing
MENTION_REGEX = Regexp.new('\[(@[^\\]]+)\]\(mention://(?:user|team)/\d+/([^)]+)\)')
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z')
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z')
WHATSAPP_CHANNEL_REGEX = Regexp.new('^\d{1,15}\z')
end

View File

@@ -0,0 +1,158 @@
## Class to generate sample data for a chatwoot test @Account.
############################################################
### Usage #####
#
# # Seed an account with all data types in this class
# Seeders::AccountSeeder.new(account: Account.find(1)).perform!
#
#
############################################################
class Seeders::AccountSeeder
def initialize(account:)
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
@account_data = ActiveSupport::HashWithIndifferentAccess.new(YAML.safe_load(Rails.root.join('lib/seeders/seed_data.yml').read))
@account = account
end
def perform!
set_up_account
seed_teams
seed_custom_roles
set_up_users
seed_labels
seed_canned_responses
seed_inboxes
seed_contacts
end
def set_up_account
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@account.inboxes.destroy_all
@account.contacts.destroy_all
@account.custom_roles.destroy_all if @account.respond_to?(:custom_roles)
end
def seed_teams
@account_data['teams'].each do |team_name|
@account.teams.create!(name: team_name)
end
end
def seed_custom_roles
return unless @account_data['custom_roles'].present? && @account.respond_to?(:custom_roles)
@account_data['custom_roles'].each do |role_data|
@account.custom_roles.create!(
name: role_data['name'],
description: role_data['description'],
permissions: role_data['permissions']
)
end
end
def seed_labels
@account_data['labels'].each do |label|
@account.labels.create!(label)
end
end
def set_up_users
@account_data['users'].each do |user|
user_record = create_user_record(user)
create_account_user(user_record, user)
add_user_to_teams(user: user_record, teams: user['team']) if user['team'].present?
end
end
private
def create_user_record(user)
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: user['email'].to_s)
user_record.skip_confirmation!
user_record.save!
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
user_record
end
def create_account_user(user_record, user)
account_user_attrs = build_account_user_attrs(user)
AccountUser.create_with(account_user_attrs).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
end
def build_account_user_attrs(user)
attrs = { role: (user['role'] || 'agent') }
custom_role = find_custom_role(user['custom_role']) if user['custom_role'].present?
attrs[:custom_role] = custom_role if custom_role
attrs
end
def add_user_to_teams(user:, teams:)
teams.each do |team|
team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present?
TeamMember.find_or_create_by!(team_id: team_record.id, user_id: user.id) unless team_record.nil?
end
end
def find_custom_role(role_name)
return nil unless @account.respond_to?(:custom_roles)
@account.custom_roles.find_by(name: role_name)
end
def seed_canned_responses(count: 50)
count.times do
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
end
end
def seed_contacts
@account_data['contacts'].each do |contact_data|
contact = @account.contacts.find_or_initialize_by(email: contact_data['email'])
if contact.new_record?
contact.update!(contact_data.slice('name', 'email'))
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
end
contact_data['conversations'].each do |conversation_data|
inbox = @account.inboxes.find_by(channel_type: conversation_data['channel'])
contact_inbox = inbox.contact_inboxes.create_or_find_by!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex))
create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data)
end
end
end
def create_conversation(contact_inbox:, conversation_data:)
assignee = User.from_email(conversation_data['assignee']) if conversation_data['assignee'].present?
conversation = contact_inbox.conversations.create!(account: contact_inbox.inbox.account, contact: contact_inbox.contact,
inbox: contact_inbox.inbox, assignee: assignee)
create_messages(conversation: conversation, messages: conversation_data['messages'])
conversation.update_labels(conversation_data[:labels]) if conversation_data[:labels].present?
conversation.update!(priority: conversation_data[:priority]) if conversation_data[:priority].present?
end
def create_messages(conversation:, messages:)
messages.each do |message_data|
sender = find_message_sender(conversation, message_data)
conversation.messages.create!(
message_data.slice('content', 'message_type').merge(
account: conversation.inbox.account, sender: sender, inbox: conversation.inbox
)
)
end
end
def find_message_sender(conversation, message_data)
if message_data['message_type'] == 'incoming'
conversation.contact
elsif message_data['sender'].present?
User.from_email(message_data['sender'])
end
end
def seed_inboxes
Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform!
end
end

View File

@@ -0,0 +1,105 @@
## Class to generate sample inboxes for a chatwoot test @Account.
############################################################
### Usage #####
#
# # Seed an account with all data types in this class
# Seeders::InboxSeeder.new(account: @Account.find(1), company_data: {name: 'PaperLayer', doamin: 'paperlayer.test'}).perform!
#
#
############################################################
class Seeders::InboxSeeder
def initialize(account:, company_data:)
raise 'Inbox Seeding is not allowed in production.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
@account = account
@company_data = company_data
end
def perform!
seed_website_inbox
seed_facebook_inbox
seed_twitter_inbox
seed_whatsapp_inbox
seed_sms_inbox
seed_email_inbox
seed_api_inbox
seed_telegram_inbox
seed_line_inbox
end
def seed_website_inbox
channel = Channel::WebWidget.create!(account: @account, website_url: "https://#{@company_data['domain']}")
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Website")
end
def seed_facebook_inbox
channel = Channel::FacebookPage.create!(account: @account, user_access_token: SecureRandom.hex, page_access_token: SecureRandom.hex,
page_id: SecureRandom.hex)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Facebook")
end
def seed_twitter_inbox
channel = Channel::TwitterProfile.create!(account: @account, twitter_access_token: SecureRandom.hex,
twitter_access_token_secret: SecureRandom.hex, profile_id: '123')
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Twitter")
end
def seed_whatsapp_inbox
# rubocop:disable Rails/SkipsModelValidations
Channel::Whatsapp.insert(
{
account_id: @account.id,
phone_number: Faker::PhoneNumber.cell_phone_in_e164,
created_at: Time.now.utc,
updated_at: Time.now.utc
},
returning: %w[id]
)
# rubocop:enable Rails/SkipsModelValidations
channel = Channel::Whatsapp.find_by(account_id: @account.id)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Whatsapp")
end
def seed_sms_inbox
channel = Channel::Sms.create!(account: @account, phone_number: Faker::PhoneNumber.cell_phone_in_e164)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Mobile")
end
def seed_email_inbox
channel = Channel::Email.create!(account: @account, email: "test#{SecureRandom.hex}@#{@company_data['domain']}",
forward_to_email: "test_fwd#{SecureRandom.hex}@#{@company_data['domain']}")
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Email")
end
def seed_api_inbox
channel = Channel::Api.create!(account: @account)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} API")
end
def seed_telegram_inbox
# rubocop:disable Rails/SkipsModelValidations
bot_token = SecureRandom.hex
Channel::Telegram.insert(
{
account_id: @account.id,
bot_name: (@company_data['name']).to_s,
bot_token: bot_token,
created_at: Time.now.utc,
updated_at: Time.now.utc
},
returning: %w[id]
)
channel = Channel::Telegram.find_by(bot_token: bot_token)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Telegram")
# rubocop:enable Rails/SkipsModelValidations
end
def seed_line_inbox
channel = Channel::Line.create!(account: @account, line_channel_id: SecureRandom.hex, line_channel_secret: SecureRandom.hex,
line_channel_token: SecureRandom.hex)
Inbox.create!(channel: channel, account: @account, name: "#{@company_data['name']} Line")
end
end

View File

@@ -0,0 +1,123 @@
module Seeders::MessageSeeder
def self.create_sample_email_collect_message(conversation)
Message.create!(
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
message_type: :template,
content_type: :input_email,
content: 'Get notified by email'
)
end
def self.create_sample_csat_collect_message(conversation)
Message.create!(
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
message_type: :template,
content_type: :input_csat,
content: 'Please rate the support'
)
end
def self.create_sample_cards_message(conversation)
Message.create!(
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
message_type: :template,
content_type: 'cards',
content: 'cards',
content_attributes: {
items: [
sample_card_item,
sample_card_item
]
}
)
end
def self.sample_card_item
{
media_url: 'https://i.imgur.com/d8Djr4k.jpg',
title: 'Acme Shoes 2.0',
description: 'Move with Acme Shoe 2.0',
actions: [
{
type: 'link',
text: 'View More',
uri: 'http://acme-shoes.inc'
},
{
type: 'postback',
text: 'Add to cart',
payload: 'ITEM_SELECTED'
}
]
}
end
def self.create_sample_input_select_message(conversation)
Message.create!(
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
message_type: :template,
content: 'Your favorite food',
content_type: 'input_select',
content_attributes: {
items: [
{ title: '🌯 Burito', value: 'Burito' },
{ title: '🍝 Pasta', value: 'Pasta' },
{ title: ' 🍱 Sushi', value: 'Sushi' },
{ title: ' 🥗 Salad', value: 'Salad' }
]
}
)
end
def self.create_sample_form_message(conversation)
Message.create!(
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
message_type: :template,
content_type: 'form',
content: 'form',
content_attributes: sample_form
)
end
def self.sample_form
{
items: [
{ name: 'email', placeholder: 'Please enter your email', type: 'email', label: 'Email', required: 'required',
pattern_error: 'Please fill this field', pattern: '^[^\s@]+@[^\s@]+\.[^\s@]+$' },
{ name: 'text_area', placeholder: 'Please enter text', type: 'text_area', label: 'Large Text', required: 'required',
pattern_error: 'Please fill this field' },
{ name: 'text', placeholder: 'Please enter text', type: 'text', label: 'text', default: 'defaut value', required: 'required',
pattern: '^[a-zA-Z ]*$', pattern_error: 'Only alphabets are allowed' },
{ name: 'select', label: 'Select Option', type: 'select', options: [{ label: '🌯 Burito', value: 'Burito' },
{ label: '🍝 Pasta', value: 'Pasta' }] }
]
}
end
def self.create_sample_articles_message(conversation)
Message.create!(
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
message_type: :template,
content: 'Tech Companies',
content_type: 'article',
content_attributes: {
items: [
{ title: 'Acme Hardware', description: 'Hardware reimagined', link: 'http://acme-hardware.inc' },
{ title: 'Acme Search', description: 'The best Search Engine', link: 'http://acme-search.inc' }
]
}
)
end
end

View File

@@ -0,0 +1,119 @@
# frozen_string_literal: true
require 'faker'
require 'active_support/testing/time_helpers'
class Seeders::Reports::ConversationCreator
include ActiveSupport::Testing::TimeHelpers
def initialize(account:, resources:)
@account = account
@contacts = resources[:contacts]
@inboxes = resources[:inboxes]
@teams = resources[:teams]
@labels = resources[:labels]
@agents = resources[:agents]
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
end
# rubocop:disable Metrics/MethodLength
def create_conversation(created_at:)
conversation = nil
should_resolve = false
resolution_time = nil
ActiveRecord::Base.transaction do
travel_to(created_at) do
conversation = build_conversation
conversation.save!
add_labels_to_conversation(conversation)
create_messages_for_conversation(conversation)
# Determine if should resolve but don't update yet
should_resolve = rand > 0.3
if should_resolve
resolution_delay = rand((30.minutes)..(24.hours))
resolution_time = created_at + resolution_delay
end
end
travel_back
end
# Now resolve outside of time travel if needed
if should_resolve && resolution_time
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:status, :resolved)
conversation.update_column(:updated_at, resolution_time)
# rubocop:enable Rails/SkipsModelValidations
# Trigger the event with proper timestamp
travel_to(resolution_time) do
trigger_conversation_resolved_event(conversation)
end
travel_back
end
conversation
end
# rubocop:enable Metrics/MethodLength
private
def build_conversation
contact = @contacts.sample
inbox = @inboxes.sample
contact_inbox = find_or_create_contact_inbox(contact, inbox)
assignee = select_assignee(inbox)
team = select_team
priority = @priorities.sample
contact_inbox.conversations.new(
account: @account,
inbox: inbox,
contact: contact,
assignee: assignee,
team: team,
priority: priority
)
end
def find_or_create_contact_inbox(contact, inbox)
inbox.contact_inboxes.find_or_create_by!(
contact: contact,
source_id: SecureRandom.hex
)
end
def select_assignee(inbox)
rand(10) < 8 ? inbox.members.sample : nil
end
def select_team
rand(10) < 7 ? @teams.sample : nil
end
def add_labels_to_conversation(conversation)
labels_to_add = @labels.sample(rand(5..20))
conversation.update_labels(labels_to_add.map(&:title))
end
def create_messages_for_conversation(conversation)
message_creator = Seeders::Reports::MessageCreator.new(
account: @account,
agents: @agents,
conversation: conversation
)
message_creator.create_messages
end
def trigger_conversation_resolved_event(conversation)
event_data = { conversation: conversation }
ReportingEventListener.instance.conversation_resolved(
Events::Base.new('conversation_resolved', Time.current, event_data)
)
end
end

View File

@@ -0,0 +1,141 @@
# frozen_string_literal: true
require 'faker'
require 'active_support/testing/time_helpers'
class Seeders::Reports::MessageCreator
include ActiveSupport::Testing::TimeHelpers
MESSAGES_PER_CONVERSATION = 5
def initialize(account:, agents:, conversation:)
@account = account
@agents = agents
@conversation = conversation
end
def create_messages
message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5)
first_agent_reply = true
message_count.times do |i|
message = create_single_message(i)
first_agent_reply = handle_reply_tracking(message, i, first_agent_reply)
end
end
def create_single_message(index)
is_incoming = index.even?
add_realistic_delay(index, is_incoming) if index.positive?
create_message(is_incoming)
end
def handle_reply_tracking(message, index, first_agent_reply)
return first_agent_reply if index.even? # Skip incoming messages
handle_agent_reply_events(message, first_agent_reply)
false # No longer first reply after any agent message
end
private
def add_realistic_delay(_message_index, is_incoming)
delay = calculate_message_delay(is_incoming)
travel(delay)
end
def calculate_message_delay(is_incoming)
if is_incoming
# Customer response time: 1 minute to 4 hours
rand((1.minute)..(4.hours))
elsif business_hours_active?(Time.current)
# Agent response time varies by business hours
rand((30.seconds)..(30.minutes))
else
rand((1.hour)..(8.hours))
end
end
def create_message(is_incoming)
if is_incoming
create_incoming_message
else
create_outgoing_message
end
end
def create_incoming_message
@conversation.messages.create!(
account: @account,
inbox: @conversation.inbox,
message_type: :incoming,
content: generate_message_content,
sender: @conversation.contact
)
end
def create_outgoing_message
sender = @conversation.assignee || @agents.sample
@conversation.messages.create!(
account: @account,
inbox: @conversation.inbox,
message_type: :outgoing,
content: generate_message_content,
sender: sender
)
end
def generate_message_content
Faker::Lorem.paragraph(sentence_count: rand(1..5))
end
def handle_agent_reply_events(message, is_first_reply)
if is_first_reply
trigger_first_reply_event(message)
else
trigger_reply_event(message)
end
end
def business_hours_active?(time)
weekday = time.wday
hour = time.hour
weekday.between?(1, 5) && hour.between?(9, 17)
end
def trigger_first_reply_event(message)
event_data = {
message: message,
conversation: message.conversation
}
ReportingEventListener.instance.first_reply_created(
Events::Base.new('first_reply_created', Time.current, event_data)
)
end
def trigger_reply_event(message)
waiting_since = calculate_waiting_since(message)
event_data = {
message: message,
conversation: message.conversation,
waiting_since: waiting_since
}
ReportingEventListener.instance.reply_created(
Events::Base.new('reply_created', Time.current, event_data)
)
end
def calculate_waiting_since(message)
last_customer_message = message.conversation.messages
.where(message_type: :incoming)
.where('created_at < ?', message.created_at)
.order(:created_at)
.last
last_customer_message&.created_at || message.conversation.created_at
end
end

View File

@@ -0,0 +1,234 @@
# frozen_string_literal: true
# Reports Data Seeder
#
# Generates realistic test data for performance testing of reports and analytics.
# Creates conversations, messages, contacts, agents, teams, and labels with proper
# reporting events (first response times, resolution times, etc.) using time travel
# to generate historical data with realistic timestamps.
#
# Usage:
# ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
#
# This will create:
# - 1000 conversations with realistic message exchanges
# - 100 contacts with realistic profiles
# - 20 agents assigned to teams and inboxes
# - 5 teams with realistic distribution
# - 30 labels with random assignments
# - 3 inboxes with agent assignments
# - Realistic reporting events with historical timestamps
#
# Note: This seeder clears existing data for the account before seeding.
require 'faker'
require_relative 'conversation_creator'
require_relative 'message_creator'
# rubocop:disable Rails/Output
class Seeders::Reports::ReportDataSeeder
include ActiveSupport::Testing::TimeHelpers
TOTAL_CONVERSATIONS = 1000
TOTAL_CONTACTS = 100
TOTAL_AGENTS = 20
TOTAL_TEAMS = 5
TOTAL_LABELS = 30
TOTAL_INBOXES = 3
MESSAGES_PER_CONVERSATION = 5
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
END_DATE = Time.current
def initialize(account:)
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
@account = account
@teams = []
@agents = []
@labels = []
@inboxes = []
@contacts = []
end
def perform!
puts "Starting reports data seeding for account: #{@account.name}"
# Clear existing data
clear_existing_data
create_teams
create_agents
create_labels
create_inboxes
create_contacts
create_conversations
puts "Completed reports data seeding for account: #{@account.name}"
end
private
def clear_existing_data
puts "Clearing existing data for account: #{@account.id}"
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@account.inboxes.destroy_all
@account.contacts.destroy_all
@account.agents.destroy_all
@account.reporting_events.destroy_all
end
def create_teams
TOTAL_TEAMS.times do |i|
team = @account.teams.create!(
name: "#{Faker::Company.industry} Team #{i + 1}"
)
@teams << team
print "\rCreating teams: #{i + 1}/#{TOTAL_TEAMS}"
end
print "\n"
end
def create_agents
TOTAL_AGENTS.times do |i|
user = create_single_agent(i)
assign_agent_to_teams(user)
@agents << user
print "\rCreating agents: #{i + 1}/#{TOTAL_AGENTS}"
end
print "\n"
end
def create_single_agent(index)
random_suffix = SecureRandom.hex(4)
user = User.create!(
name: Faker::Name.name,
email: "agent_#{index + 1}_#{random_suffix}@#{@account.domain || 'example.com'}",
password: 'Password1!.',
confirmed_at: Time.current
)
user.skip_confirmation!
user.save!
AccountUser.create!(
account_id: @account.id,
user_id: user.id,
role: :agent
)
user
end
def assign_agent_to_teams(user)
teams_to_assign = @teams.sample(rand(1..3))
teams_to_assign.each do |team|
TeamMember.create!(
team_id: team.id,
user_id: user.id
)
end
end
def create_labels
TOTAL_LABELS.times do |i|
label = @account.labels.create!(
title: "Label-#{i + 1}-#{Faker::Lorem.word}",
description: Faker::Company.catch_phrase,
color: Faker::Color.hex_color
)
@labels << label
print "\rCreating labels: #{i + 1}/#{TOTAL_LABELS}"
end
print "\n"
end
def create_inboxes
TOTAL_INBOXES.times do |_i|
inbox = create_single_inbox
assign_agents_to_inbox(inbox)
@inboxes << inbox
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
end
print "\n"
end
def create_single_inbox
channel = Channel::WebWidget.create!(
website_url: "https://#{Faker::Internet.domain_name}",
account_id: @account.id
)
@account.inboxes.create!(
name: "#{Faker::Company.name} Website",
channel: channel
)
end
def assign_agents_to_inbox(inbox)
agents_to_assign = if @inboxes.empty?
# First inbox gets all agents to ensure coverage
@agents
else
# Subsequent inboxes get random selection with some overlap
min_agents = [@agents.size / TOTAL_INBOXES, 10].max
max_agents = [(@agents.size * 0.8).to_i, 50].min
@agents.sample(rand(min_agents..max_agents))
end
agents_to_assign.each do |agent|
InboxMember.create!(inbox: inbox, user: agent)
end
end
def create_contacts
TOTAL_CONTACTS.times do |i|
contact = @account.contacts.create!(
name: Faker::Name.name,
email: Faker::Internet.email,
phone_number: Faker::PhoneNumber.cell_phone_in_e164,
identifier: SecureRandom.uuid,
additional_attributes: {
company: Faker::Company.name,
city: Faker::Address.city,
country: Faker::Address.country,
customer_since: Faker::Date.between(from: 2.years.ago, to: Time.zone.today)
}
)
@contacts << contact
print "\rCreating contacts: #{i + 1}/#{TOTAL_CONTACTS}"
end
print "\n"
end
def create_conversations
conversation_creator = Seeders::Reports::ConversationCreator.new(
account: @account,
resources: {
contacts: @contacts,
inboxes: @inboxes,
teams: @teams,
labels: @labels,
agents: @agents
}
)
TOTAL_CONVERSATIONS.times do |i|
created_at = Faker::Time.between(from: START_DATE, to: END_DATE)
conversation_creator.create_conversation(created_at: created_at)
completion_percentage = ((i + 1).to_f / TOTAL_CONVERSATIONS * 100).round
print "\rCreating conversations: #{i + 1}/#{TOTAL_CONVERSATIONS} (#{completion_percentage}%)"
end
print "\n"
end
end
# rubocop:enable Rails/Output

View File

@@ -0,0 +1,480 @@
company:
name: 'PaperLayer'
domain: 'paperlayer.test'
users:
- name: 'Michael Scott'
gender: male
email: 'michael_scott@paperlayer.test'
team:
- 'sales'
- 'management'
- 'administration'
- 'warehouse'
role: 'administrator'
- name: 'David Wallace'
gender: male
email: 'david@paperlayer.test'
team:
- 'Management'
- name: 'Deangelo Vickers'
gender: male
email: 'deangelo@paperlayer.test'
team:
- 'Management'
- name: 'Jo Bennett'
gender: female
email: 'jo@paperlayer.test'
team:
- 'Management'
- name: 'Josh Porter'
gender: male
email: 'josh@paperlayer.test'
team:
- 'Management'
- name: 'Charles Miner'
gender: male
email: 'charles@paperlayer.test'
team:
- 'Management'
- name: 'Ed Truck'
gender: male
email: 'ed@paperlayer.test'
team:
- 'Management'
- name: 'Dan Gore'
gender: male
email: 'dan@paperlayer.test'
team:
- 'Management'
- name: 'Craig D'
gender: male
email: 'craig@paperlayer.test'
team:
- 'Management'
- name: 'Troy Underbridge'
gender: male
email: 'troy@paperlayer.test'
team:
- 'Management'
- name: 'Karen Filippelli'
gender: female
email: 'karn@paperlayer.test'
team:
- 'Sales'
custom_role: 'Sales Representative'
- name: 'Danny Cordray'
gender: female
email: 'danny@paperlayer.test'
team:
- 'Sales'
custom_role: 'Customer Support Lead'
- name: 'Ben Nugent'
gender: male
email: 'ben@paperlayer.test'
team:
- 'Sales'
custom_role: 'Junior Agent'
- name: 'Todd Packer'
gender: male
email: 'todd@paperlayer.test'
team:
- 'Sales'
custom_role: 'Sales Representative'
- name: 'Cathy Simms'
gender: female
email: 'cathy@paperlayer.test'
team:
- 'Administration'
custom_role: 'Knowledge Manager'
- name: 'Hunter Jo'
gender: male
email: 'hunter@paperlayer.test'
team:
- 'Administration'
custom_role: 'Analytics Specialist'
- name: 'Rolando Silva'
gender: male
email: 'rolando@paperlayer.test'
team:
- 'Administration'
custom_role: 'Junior Agent'
- name: 'Stephanie Wilson'
gender: female
email: 'stephanie@paperlayer.test'
team:
- 'Administration'
custom_role: 'Escalation Handler'
- name: 'Jordan Garfield'
gender: male
email: 'jorodan@paperlayer.test'
team:
- 'Administration'
- name: 'Ronni Carlo'
gender: male
email: 'ronni@paperlayer.test'
team:
- 'Administration'
- name: 'Lonny Collins'
gender: female
email: 'lonny@paperlayer.test'
team:
- 'Warehouse'
custom_role: 'Customer Support Lead'
- name: 'Madge Madsen'
gender: female
email: 'madge@paperlayer.test'
team:
- 'Warehouse'
- name: 'Glenn Max'
gender: female
email: 'glenn@paperlayer.test'
team:
- 'Warehouse'
- name: 'Jerry DiCanio'
gender: male
email: 'jerry@paperlayer.test'
team:
- 'Warehouse'
- name: 'Phillip Martin'
gender: male
email: 'phillip@paperlayer.test'
team:
- 'Warehouse'
- name: 'Michael Josh'
gender: male
email: 'michale_josh@paperlayer.test'
team:
- 'Warehouse'
- name: 'Matt Hudson'
gender: male
email: 'matt@paperlayer.test'
team:
- 'Warehouse'
- name: 'Gideon'
gender: male
email: 'gideon@paperlayer.test'
team:
- 'Warehouse'
- name: 'Bruce'
gender: male
email: 'bruce@paperlayer.test'
team:
- 'Warehouse'
- name: 'Frank'
gender: male
email: 'frank@paperlayer.test'
team:
- 'Warehouse'
- name: 'Louanne Kelley'
gender: female
email: 'louanne@paperlayer.test'
- name: 'Devon White'
gender: male
email: 'devon@paperlayer.test'
custom_role: 'Escalation Handler'
- name: 'Kendall'
gender: male
email: 'kendall@paperlayer.test'
- email: 'sadiq@paperlayer.test'
name: 'Sadiq'
gender: male
teams:
- '💰 Sales'
- '💼 Management'
- '👩‍💼 Administration'
- '🚛 Warehouse'
custom_roles:
- name: 'Customer Support Lead'
description: 'Lead support agent with full conversation and contact management'
permissions:
- 'conversation_manage'
- 'contact_manage'
- 'report_manage'
- name: 'Sales Representative'
description: 'Sales team member with conversation and contact access'
permissions:
- 'conversation_unassigned_manage'
- 'conversation_participating_manage'
- 'contact_manage'
- name: 'Knowledge Manager'
description: 'Manages knowledge base and participates in conversations'
permissions:
- 'knowledge_base_manage'
- 'conversation_participating_manage'
- name: 'Junior Agent'
description: 'Entry-level agent with basic conversation access'
permissions:
- 'conversation_participating_manage'
- name: 'Analytics Specialist'
description: 'Focused on reports and data analysis'
permissions:
- 'report_manage'
- 'conversation_participating_manage'
- name: 'Escalation Handler'
description: 'Handles unassigned conversations and escalations'
permissions:
- 'conversation_unassigned_manage'
- 'conversation_participating_manage'
- 'contact_manage'
labels:
- title: 'billing'
color: '#28AD21'
show_on_sidebar: true
- title: 'software'
color: '#8F6EF2'
show_on_sidebar: true
- title: 'delivery'
color: '#A2FDD5'
show_on_sidebar: true
- title: 'ops-handover'
color: '#A53326'
show_on_sidebar: true
- title: 'premium-customer'
color: '#6FD4EF'
show_on_sidebar: true
- title: 'lead'
color: '#F161C8'
show_on_sidebar: true
contacts:
- name: "Lorrie Trosdall"
email: "ltrosdall0@bravesites.test"
gender: 'female'
conversations:
- channel: Channel::WebWidget
messages:
- message_type: incoming
content: Hi, I'm having trouble logging in to my account.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: Hi! Sorry to hear that. Can you please provide me with your username and email address so I can look into it for you?
- name: "Tiffanie Cloughton"
email: "tcloughton1@newyorker.test"
gender: 'female'
conversations:
- channel: Channel::FacebookPage
messages:
- message_type: incoming
content: Hi, I need some help with my billing statement.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: Hello! I'd be happy to assist you with that. Can you please tell me which billing statement you're referring to?
- name: "Melonie Keatch"
email: "mkeatch2@reuters.test"
gender: 'female'
conversations:
- channel: Channel::TwitterProfile
messages:
- message_type: incoming
content: Hi, I think I accidentally deleted some important files. Can you help me recover them?
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: Of course! Can you please tell me what type of files they were and where they were located on your device?
- name: "Olin Canniffe"
email: "ocanniffe3@feedburner.test"
gender: 'male'
conversations:
- channel: Channel::Whatsapp
source_id: "123456723"
messages:
- message_type: incoming
content: Hi, I'm having trouble connecting to the internet.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: I'm sorry to hear that. Have you tried restarting your modem/router? If that doesn't work, please let me know and I can provide further assistance.
- name: "Viviene Corp"
email: "vcorp4@instagram.test"
gender: 'female'
conversations:
- channel: Channel::Sms
source_id: "+1234567"
messages:
- message_type: incoming
content: Hi, I'm having trouble with the mobile app. It keeps crashing.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: I'm sorry to hear that. Can you please try uninstalling and reinstalling the app and see if that helps? If not, please let me know and I can look into it further.
- name: "Drake Pittway"
email: "dpittway5@chron.test"
gender: 'male'
conversations:
- channel: Channel::Line
messages:
- message_type: incoming
content: Hi, I'm trying to update my account information but it won't save.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: Sorry for the inconvenience. Can you please provide me with the specific information you're trying to update and the error message you're receiving?
- name: "Klaus Crawley"
email: "kcrawley6@narod.ru"
gender: 'male'
conversations:
- channel: Channel::WebWidget
messages:
- message_type: incoming
content: Hi, I need some help setting up my new device.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: No problem! Can you please tell me the make and model of your device and what specifically you need help with?
- name: "Bing Cusworth"
email: "bcusworth7@arstechnica.test"
gender: 'male'
conversations:
- channel: Channel::TwitterProfile
messages:
- message_type: incoming
content: Hi, I accidentally placed an order for the wrong item. Can I cancel it?
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: I'm sorry to hear that. Can you please provide me with your order number and I'll see if I can cancel it for you?
- name: "Claus Jira"
email: "cjira8@comcast.net"
gender: 'male'
conversations:
- channel: Channel::Whatsapp
source_id: "12323432"
messages:
- message_type: incoming
content: Hi, I'm having trouble with my email. I can't seem to send or receive any messages.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: I'm sorry to hear that. Can you please tell me what email client you're using and if you're receiving any error messages?
- name: "Quent Dalliston"
email: "qdalliston9@zimbio.test"
gender: 'male'
conversations:
- channel: Channel::Whatsapp
source_id: "12342234324"
messages:
- message_type: incoming
content: Hi, I need some help resetting my password.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: Sure! Can you please provide me with your username or email address and I'll send you a password reset link?
- name: "Coreen Mewett"
email: "cmewetta@home.pl"
gender: 'female'
conversations:
- channel: Channel::FacebookPage
messages:
- message_type: incoming
content: Hi, I think someone may have hacked into my account. What should I do?
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: I'm sorry to hear that. Please change your password immediately and enable two-factor authentication if you haven't already done so. I can also assist you in reviewing your account activity if needed.
- name: "Benyamin Janeway"
email: "bjanewayb@ustream.tv"
gender: 'male'
conversations:
- channel: Channel::Line
messages:
- message_type: incoming
content: Hi, I have a question about your product features.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: Sure thing! What specific feature are you interested in learning more about?
- name: "Cordell Dalinder"
email: "cdalinderc@msn.test"
gender: 'male'
conversations:
- channel: Channel::Email
source_id: "cdalinderc@msn.test"
messages:
- message_type: incoming
content: Hi, I need help setting up my new printer.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: No problem! Can you please provide me with the make and model of your printer and what type of device you'll be connecting it to?
- name: "Merrile Petruk"
email: "mpetrukd@wunderground.test"
gender: 'female'
conversations:
- channel: Channel::Email
source_id: "mpetrukd@wunderground.test"
priority: urgent
messages:
- message_type: incoming
content: Hi, I'm having trouble accessing a file that I shared with someone.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: I'm sorry to hear that. Can you please tell me which file you're having trouble accessing and who you shared it with? I'll do my best to help you regain access.
- name: "Nathaniel Vannuchi"
email: "nvannuchie@photobucket.test"
gender: 'male'
conversations:
- channel: Channel::FacebookPage
priority: high
messages:
- message_type: incoming
content: "Hey there,I need some help with billing, my card is not working on the website."
- name: "Olia Olenchenko"
email: "oolenchenkof@bluehost.test"
gender: 'female'
conversations:
- channel: Channel::WebWidget
priority: high
assignee: michael_scott@paperlayer.test
messages:
- message_type: incoming
content: "Billing section is not working, it throws some error."
- name: "Elisabeth Derington"
email: "ederingtong@printfriendly.test"
gender: 'female'
conversations:
- channel: Channel::Whatsapp
priority: high
source_id: "1223423567"
labels:
- billing
- delivery
- ops-handover
- premium-customer
messages:
- message_type: incoming
content: "Hey \n I didn't get the product delivered, but it shows it is delivered to my address. Please check"
- name: "Willy Castelot"
email: "wcasteloth@exblog.jp"
gender: 'male'
conversations:
- channel: Channel::WebWidget
priority: medium
labels:
- software
- ops-handover
messages:
- message_type: incoming
content: "Hey there, \n I need some help with the product, my button is not working on the website."
- name: "Ophelia Folkard"
email: "ofolkardi@taobao.test"
gender: 'female'
conversations:
- channel: Channel::WebWidget
priority: low
assignee: michael_scott@paperlayer.test
labels:
- billing
- software
- lead
messages:
- message_type: incoming
content: "Hey, \n My card is not working on your website. Please help"
- name: "Candice Matherson"
email: "cmathersonj@va.test"
gender: 'female'
conversations:
- channel: Channel::Email
priority: urgent
source_id: "cmathersonj@va.test"
assignee: michael_scott@paperlayer.test
labels:
- billing
- lead
messages:
- message_type: incoming
content: "Hey, \n I'm looking for some help to figure out if it is the right product for me."
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: Welcome to PaperLayer. Our Team will be getting back you shortly.
- message_type: outgoing
sender: michael_scott@paperlayer.test
content: How may i help you ?
sender: michael_scott@paperlayer.test

View File

View File

@@ -0,0 +1,100 @@
# Apply SLA Policy to Conversations
#
# This task applies an SLA policy to existing conversations that don't have one assigned.
# It processes conversations in batches and only affects conversations with sla_policy_id = nil.
#
# Usage Examples:
# # Using arguments (may need escaping in some shells)
# bundle exec rake "sla:apply_to_conversations[19,1,500]"
#
# # Using environment variables (recommended)
# SLA_POLICY_ID=19 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations
#
# Parameters:
# SLA_POLICY_ID: ID of the SLA policy to apply (required)
# ACCOUNT_ID: ID of the account (required)
# BATCH_SIZE: Number of conversations to process (default: 1000)
#
# Notes:
# - Only runs in development environment
# - Processes conversations in order of newest first (id DESC)
# - Safe to run multiple times - skips conversations that already have SLA policies
# - Creates AppliedSla records automatically via Rails callbacks
# - SlaEvent records are created later by background jobs when violations occur
#
# rubocop:disable Metrics/BlockLength
namespace :sla do
desc 'Apply SLA policy to existing conversations'
task :apply_to_conversations, [:sla_policy_id, :account_id, :batch_size] => :environment do |_t, args|
unless Rails.env.development?
puts 'This task can only be run in the development environment.'
puts "Current environment: #{Rails.env}"
exit(1)
end
sla_policy_id = args[:sla_policy_id] || ENV.fetch('SLA_POLICY_ID', nil)
account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil)
batch_size = (args[:batch_size] || ENV['BATCH_SIZE'] || 1000).to_i
if sla_policy_id.blank?
puts 'Error: SLA_POLICY_ID is required'
puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]'
puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations'
exit(1)
end
if account_id.blank?
puts 'Error: ACCOUNT_ID is required'
puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]'
puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations'
exit(1)
end
account = Account.find_by(id: account_id)
unless account
puts "Error: Account with ID #{account_id} not found"
exit(1)
end
sla_policy = account.sla_policies.find_by(id: sla_policy_id)
unless sla_policy
puts "Error: SLA Policy with ID #{sla_policy_id} not found for Account #{account_id}"
exit(1)
end
conversations = account.conversations.where(sla_policy_id: nil).order(id: :desc).limit(batch_size)
total_count = conversations.count
if total_count.zero?
puts 'No conversations found without SLA policy'
exit(0)
end
puts "Applying SLA Policy '#{sla_policy.name}' (ID: #{sla_policy_id}) to #{total_count} conversations in Account #{account_id}"
puts "Processing in batches of #{batch_size}"
puts "Started at: #{Time.current}"
start_time = Time.current
processed_count = 0
error_count = 0
conversations.find_in_batches(batch_size: batch_size) do |batch|
batch.each do |conversation|
conversation.update!(sla_policy_id: sla_policy_id)
processed_count += 1
puts "Processed #{processed_count}/#{total_count} conversations" if (processed_count % 100).zero?
rescue StandardError => e
error_count += 1
puts "Error applying SLA to conversation #{conversation.id}: #{e.message}"
end
end
elapsed_time = Time.current - start_time
puts "\nCompleted!"
puts "Successfully processed: #{processed_count} conversations"
puts "Errors encountered: #{error_count}" if error_count.positive?
puts "Total time: #{elapsed_time.round(2)}s"
puts "Average time per conversation: #{(elapsed_time / processed_count).round(3)}s" if processed_count.positive?
end
end
# rubocop:enable Metrics/BlockLength

View File

@@ -0,0 +1,21 @@
# Asset clean logic taken from the article https://chwt.app/heroku-slug-size
namespace :assets do
desc "Remove 'node_modules' folder"
task rm_node_modules: :environment do
Rails.logger.info 'Removing node_modules folder'
FileUtils.remove_dir('node_modules', true)
end
end
skip_clean = %w[no false n f].include?(ENV.fetch('WEBPACKER_PRECOMPILE', nil))
unless skip_clean
if Rake::Task.task_defined?('assets:clean')
Rake::Task['assets:clean'].enhance do
Rake::Task['assets:rm_node_modules'].invoke
end
else
Rake::Task.define_task('assets:clean' => 'assets:rm_node_modules')
end
end

View File

@@ -0,0 +1,61 @@
# NOTE: only doing this in development as some production environments (Heroku)
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development?
require 'annotate_rb'
AnnotateRb::Core.load_rake_tasks
task :set_annotation_options do
# You can override any of these by setting an environment variable of the
# same name.
AnnotateRb::Options.set_defaults(
'additional_file_patterns' => [],
'routes' => 'false',
'models' => 'true',
'position_in_routes' => 'before',
'position_in_class' => 'before',
'position_in_test' => 'before',
'position_in_fixture' => 'before',
'position_in_factory' => 'before',
'position_in_serializer' => 'before',
'show_foreign_keys' => 'true',
'show_complete_foreign_keys' => 'false',
'show_indexes' => 'true',
'simple_indexes' => 'false',
'model_dir' => [
'app/models',
'enterprise/app/models',
],
'root_dir' => '',
'include_version' => 'false',
'require' => '',
'exclude_tests' => 'true',
'exclude_fixtures' => 'true',
'exclude_factories' => 'true',
'exclude_serializers' => 'true',
'exclude_scaffolds' => 'true',
'exclude_controllers' => 'true',
'exclude_helpers' => 'true',
'exclude_sti_subclasses' => 'false',
'ignore_model_sub_dir' => 'false',
'ignore_columns' => nil,
'ignore_routes' => nil,
'ignore_unknown_models' => 'false',
'hide_limit_column_types' => 'integer,bigint,boolean',
'hide_default_column_types' => 'json,jsonb,hstore',
'skip_on_db_migrate' => 'false',
'format_bare' => 'true',
'format_rdoc' => 'false',
'format_markdown' => 'false',
'sort' => 'false',
'force' => 'false',
'frozen' => 'false',
'classified_sort' => 'true',
'trace' => 'false',
'wrapper_open' => nil,
'wrapper_close' => nil,
'with_comment' => 'true'
)
end
end

View File

@@ -0,0 +1,13 @@
# ref: https://github.com/rails/rails/issues/43906#issuecomment-1094380699
# https://github.com/rails/rails/issues/43906#issuecomment-1099992310
task before_assets_precompile: :environment do
# run a command which starts your packaging
system('pnpm install')
system('echo "-------------- Bulding SDK for Production --------------"')
system('pnpm run build:sdk')
system('echo "-------------- Bulding App for Production --------------"')
end
# every time you execute 'rake assets:precompile'
# run 'before_assets_precompile' first
Rake::Task['assets:precompile'].enhance %w[before_assets_precompile]

View File

@@ -0,0 +1,176 @@
# Generate Bulk Conversations
#
# This task creates bulk conversations with fake contacts and movie dialogue messages
# for testing purposes. Each conversation gets random messages between contacts and agents.
#
# Usage Examples:
# # Using arguments (may need escaping in some shells)
# bundle exec rake "conversations:generate_bulk[100,1,1]"
#
# # Using environment variables (recommended)
# COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk
#
# # Generate 50 conversations
# COUNT=50 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk
#
# Parameters:
# COUNT: Number of conversations to create (default: 10)
# ACCOUNT_ID: ID of the account (required)
# INBOX_ID: ID of the inbox that belongs to the account (required)
#
# What it creates:
# - Unique contacts with fake names, emails, phone numbers
# - Conversations with random status (open/resolved/pending)
# - 3-10 messages per conversation with movie quotes
# - Alternating incoming/outgoing message flow
#
# Notes:
# - Only runs in development environment
# - Creates realistic test data for conversation testing
# - Progress shown every 10 conversations
# - All contacts get unique email addresses to avoid conflicts
#
# rubocop:disable Metrics/BlockLength
namespace :conversations do
desc 'Generate bulk conversations with contacts and movie dialogue messages'
task :generate_bulk, [:count, :account_id, :inbox_id] => :environment do |_t, args|
unless Rails.env.development?
puts 'This task can only be run in the development environment.'
puts "Current environment: #{Rails.env}"
exit(1)
end
count = (args[:count] || ENV['COUNT'] || 10).to_i
account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil)
inbox_id = args[:inbox_id] || ENV.fetch('INBOX_ID', nil)
if account_id.blank?
puts 'Error: ACCOUNT_ID is required'
puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]'
puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk'
exit(1)
end
if inbox_id.blank?
puts 'Error: INBOX_ID is required'
puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]'
puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk'
exit(1)
end
account = Account.find_by(id: account_id)
inbox = Inbox.find_by(id: inbox_id)
unless account
puts "Error: Account with ID #{account_id} not found"
exit(1)
end
unless inbox
puts "Error: Inbox with ID #{inbox_id} not found"
exit(1)
end
unless inbox.account_id == account.id
puts "Error: Inbox #{inbox_id} does not belong to Account #{account_id}"
exit(1)
end
puts "Generating #{count} conversations for Account ##{account.id} in Inbox ##{inbox.id}..."
puts "Started at: #{Time.current}"
start_time = Time.current
created_count = 0
count.times do |i|
contact = create_contact(account)
contact_inbox = create_contact_inbox(contact, inbox)
conversation = create_conversation(contact_inbox)
add_messages(conversation)
created_count += 1
puts "Created conversation #{i + 1}/#{count} (ID: #{conversation.id})" if ((i + 1) % 10).zero?
rescue StandardError => e
puts "Error creating conversation #{i + 1}: #{e.message}"
puts e.backtrace.first(5).join("\n")
end
elapsed_time = Time.current - start_time
puts "\nCompleted!"
puts "Successfully created: #{created_count} conversations"
puts "Total time: #{elapsed_time.round(2)}s"
puts "Average time per conversation: #{(elapsed_time / created_count).round(3)}s" if created_count.positive?
end
def create_contact(account)
Contact.create!(
account: account,
name: Faker::Name.name,
email: "#{SecureRandom.uuid}@example.com",
phone_number: generate_e164_phone_number,
additional_attributes: {
source: 'bulk_generator',
company: Faker::Company.name,
city: Faker::Address.city
}
)
end
def generate_e164_phone_number
country_code = [1, 44, 61, 91, 81].sample
subscriber_number = rand(1_000_000..9_999_999_999).to_s
subscriber_number = subscriber_number[0...(15 - country_code.to_s.length)]
"+#{country_code}#{subscriber_number}"
end
def create_contact_inbox(contact, inbox)
ContactInboxBuilder.new(
contact: contact,
inbox: inbox
).perform
end
def create_conversation(contact_inbox)
ConversationBuilder.new(
params: ActionController::Parameters.new(
status: %w[open resolved pending].sample,
additional_attributes: {},
custom_attributes: {}
),
contact_inbox: contact_inbox
).perform
end
def add_messages(conversation)
num_messages = rand(3..10)
message_type = %w[incoming outgoing].sample
num_messages.times do
message_type = message_type == 'incoming' ? 'outgoing' : 'incoming'
create_message(conversation, message_type)
end
end
def create_message(conversation, message_type)
sender = if message_type == 'incoming'
conversation.contact
else
conversation.account.users.sample || conversation.account.administrators.first
end
conversation.messages.create!(
account: conversation.account,
inbox: conversation.inbox,
sender: sender,
message_type: message_type,
content: generate_movie_dialogue,
content_type: :text,
private: false
)
end
def generate_movie_dialogue
Faker::Movie.quote
end
end
# rubocop:enable Metrics/BlockLength

View File

@@ -0,0 +1,235 @@
require 'io/console'
require 'readline'
namespace :captain do
desc 'Start interactive chat with Captain assistant - Usage: rake captain:chat[assistant_id] or rake captain:chat -- assistant_id'
task :chat, [:assistant_id] => :environment do |_, args|
assistant_id = args[:assistant_id] || ARGV[1]
unless assistant_id
puts '❌ Please provide an assistant ID'
puts 'Usage: rake captain:chat[assistant_id]'
puts "\nAvailable assistants:"
Captain::Assistant.includes(:account).each do |assistant|
puts " ID: #{assistant.id} - #{assistant.name} (Account: #{assistant.account.name})"
end
exit 1
end
assistant = Captain::Assistant.find_by(id: assistant_id)
unless assistant
puts "❌ Assistant with ID #{assistant_id} not found"
exit 1
end
# Clear ARGV to prevent gets from reading files
ARGV.clear
chat_session = CaptainChatSession.new(assistant)
chat_session.start
end
end
class CaptainChatSession
def initialize(assistant)
@assistant = assistant
@message_history = []
end
def start
show_assistant_info
show_instructions
chat_loop
show_exit_message
end
private
def show_instructions
puts "💡 Type 'exit', 'quit', or 'bye' to end the session"
puts "💡 Type 'clear' to clear message history"
puts('-' * 50)
end
def chat_loop
loop do
puts '' # Add spacing before prompt
user_input = Readline.readline('👤 You: ', true)
next unless user_input # Handle Ctrl+D
break unless handle_user_input(user_input.strip)
end
end
def handle_user_input(user_input)
case user_input.downcase
when 'exit', 'quit', 'bye'
false
when 'clear'
clear_history
true
when ''
true
else
process_user_message(user_input)
true
end
end
def show_exit_message
puts "\nChat session ended"
puts "Final conversation log has #{@message_history.length} messages"
end
def show_assistant_info
show_basic_info
show_scenarios
show_available_tools
puts ''
end
def show_basic_info
puts "🤖 Starting chat with #{@assistant.name}"
puts "🏢 Account: #{@assistant.account.name}"
puts "🆔 Assistant ID: #{@assistant.id}"
end
def show_scenarios
scenarios = @assistant.scenarios.enabled
if scenarios.any?
puts "⚡ Enabled Scenarios (#{scenarios.count}):"
scenarios.each { |scenario| display_scenario(scenario) }
else
puts '⚡ No scenarios enabled'
end
end
def display_scenario(scenario)
tools_count = scenario.tools&.length || 0
puts "#{scenario.title} (#{tools_count} tools)"
return if scenario.description.blank?
description = truncate_description(scenario.description)
puts " #{description}"
end
def truncate_description(description)
description.length > 60 ? "#{description[0..60]}..." : description
end
def show_available_tools
available_tools = @assistant.available_tool_ids
if available_tools.any?
puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}"
else
puts '🔧 No tools available'
end
end
def process_user_message(user_input)
add_to_history('user', user_input)
begin
print "🤖 #{@assistant.name}: "
@current_system_messages = []
result = generate_assistant_response
display_response(result)
rescue StandardError => e
handle_error(e)
end
end
def generate_assistant_response
runner = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, callbacks: build_callbacks)
runner.generate_response(message_history: @message_history)
end
def build_callbacks
{
on_agent_thinking: method(:handle_agent_thinking),
on_tool_start: method(:handle_tool_start),
on_tool_complete: method(:handle_tool_complete),
on_agent_handoff: method(:handle_agent_handoff)
}
end
def handle_agent_thinking(agent, _input)
agent_name = extract_name(agent)
@current_system_messages << "#{agent_name} is thinking..."
add_to_history('system', "#{agent_name} is thinking...")
end
def handle_tool_start(tool, _args)
tool_name = extract_tool_name(tool)
@current_system_messages << "Using tool: #{tool_name}"
add_to_history('system', "Using tool: #{tool_name}")
end
def handle_tool_complete(tool, _result)
tool_name = extract_tool_name(tool)
@current_system_messages << "Tool #{tool_name} completed"
add_to_history('system', "Tool #{tool_name} completed")
end
def handle_agent_handoff(from, to, reason)
@current_system_messages << "Handoff: #{extract_name(from)}#{extract_name(to)} (#{reason})"
add_to_history('system', "Agent handoff: #{extract_name(from)}#{extract_name(to)} (#{reason})")
end
def display_response(result)
response_text = result['response'] || 'No response generated'
reasoning = result['reasoning']
puts dim_text("\n#{@current_system_messages.join("\n")}") if @current_system_messages.any?
puts response_text
puts dim_italic_text("(Reasoning: #{reasoning})") if reasoning && reasoning != 'Processed by agent'
add_to_history('assistant', response_text, reasoning: reasoning)
end
def handle_error(error)
error_msg = "Error: #{error.message}"
puts "#{error_msg}"
add_to_history('system', error_msg)
end
def add_to_history(role, content, agent_name: nil, reasoning: nil)
message = {
role: role,
content: content,
timestamp: Time.current,
agent_name: agent_name || (role == 'assistant' ? @assistant.name : nil)
}
message[:reasoning] = reasoning if reasoning
@message_history << message
end
def clear_history
@message_history.clear
puts 'Message history cleared'
end
def dim_text(text)
# ANSI escape code for very dim gray text (bright black/dark gray)
"\e[90m#{text}\e[0m"
end
def dim_italic_text(text)
# ANSI escape codes for dim gray + italic text
"\e[90m\e[3m#{text}\e[0m"
end
def extract_tool_name(tool)
return tool if tool.is_a?(String)
tool.class.name.split('::').last.gsub('Tool', '')
rescue StandardError
tool.to_s
end
def extract_name(obj)
obj.respond_to?(:name) ? obj.name : obj.to_s
end
end

View File

@@ -0,0 +1,12 @@
namespace :companies do
desc 'Backfill companies from existing contact email domains'
task backfill: :environment do
puts 'Starting company backfill migration...'
puts 'This will process all accounts and create companies from contact email domains.'
puts 'The job will run in the background via Sidekiq'
puts ''
Migration::CompanyBackfillJob.perform_later
puts 'Company backfill job has been enqueued.'
puts 'Monitor progress in logs or Sidekiq dashboard.'
end
end

View File

@@ -0,0 +1,31 @@
# We are hooking config loader to run automatically everytime migration is executed
Rake::Task['db:migrate'].enhance do
if ActiveRecord::Base.connection.table_exists? 'installation_configs'
puts 'Loading Installation config'
ConfigLoader.new.process
end
end
# we are creating a custom database prepare task
# the default rake db:prepare task isn't ideal for environments like heroku
# In heroku the database is already created before the first run of db:prepare
# In this case rake db:prepare tries to run db:migrate from all the way back from the beginning
# Since the assumption is migrations are only run after schema load from a point x, this could lead to things breaking.
# ref: https://github.com/rails/rails/blob/main/activerecord/lib/active_record/railties/databases.rake#L356
db_namespace = namespace :db do
desc 'Runs setup if database does not exist, or runs migrations if it does'
task chatwoot_prepare: :load_config do
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config|
ActiveRecord::Base.establish_connection(db_config.configuration_hash)
unless ActiveRecord::Base.connection.table_exists? 'ar_internal_metadata'
db_namespace['load_config'].invoke if ActiveRecord.schema_format == :ruby
ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV.fetch('SCHEMA', nil))
db_namespace['seed'].invoke
end
db_namespace['migrate'].invoke
rescue ActiveRecord::NoDatabaseError
db_namespace['setup'].invoke
end
end
end

View File

@@ -0,0 +1,126 @@
# frozen_string_literal: true
# rubocop:disable Metrics/BlockLength
namespace :chatwoot do
namespace :dev do
desc 'Toggle between Chatwoot variants with interactive menu'
task toggle_variant: :environment do
# Only allow in development environment
return unless Rails.env.development?
show_current_variant
show_variant_menu
handle_user_selection
end
desc 'Show current Chatwoot variant status'
task show_variant: :environment do
return unless Rails.env.development?
show_current_variant
end
private
def show_current_variant
puts "\n#{('=' * 50)}"
puts '🚀 CHATWOOT VARIANT MANAGER'
puts '=' * 50
# Check InstallationConfig
deployment_env = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')&.value
pricing_plan = InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value
# Determine current variant based on configs
current_variant = if deployment_env == 'cloud'
'Cloud'
elsif pricing_plan == 'enterprise'
'Enterprise'
else
'Community'
end
puts "📊 Current Variant: #{current_variant}"
puts " Deployment Environment: #{deployment_env || 'Not set'}"
puts " Pricing Plan: #{pricing_plan || 'community'}"
puts ''
end
def show_variant_menu
puts '🎯 Select a variant to switch to:'
puts ''
puts '1. 🆓 Community (Free version with basic features)'
puts '2. 🏢 Enterprise (Self-hosted with premium features)'
puts '3. 🌥️ Cloud (Cloud deployment with premium features)'
puts ''
puts '0. ❌ Cancel'
puts ''
print 'Enter your choice (0-3): '
end
def handle_user_selection
choice = $stdin.gets.chomp
case choice
when '1'
switch_to_variant('Community') { configure_community_variant }
when '2'
switch_to_variant('Enterprise') { configure_enterprise_variant }
when '3'
switch_to_variant('Cloud') { configure_cloud_variant }
when '0'
cancel_operation
else
invalid_choice
end
puts "\n🎉 Changes applied successfully! No restart required."
end
def switch_to_variant(variant_name)
puts "\n🔄 Switching to #{variant_name} variant..."
yield
clear_cache
puts "✅ Successfully switched to #{variant_name} variant!"
end
def cancel_operation
puts "\n❌ Cancelled. No changes made."
exit 0
end
def invalid_choice
puts "\n❌ Invalid choice. Please select 0-3."
puts 'No changes made.'
exit 1
end
def configure_community_variant
update_installation_config('DEPLOYMENT_ENV', 'self-hosted')
update_installation_config('INSTALLATION_PRICING_PLAN', 'community')
end
def configure_enterprise_variant
update_installation_config('DEPLOYMENT_ENV', 'self-hosted')
update_installation_config('INSTALLATION_PRICING_PLAN', 'enterprise')
end
def configure_cloud_variant
update_installation_config('DEPLOYMENT_ENV', 'cloud')
update_installation_config('INSTALLATION_PRICING_PLAN', 'enterprise')
end
def update_installation_config(name, value)
config = InstallationConfig.find_or_initialize_by(name: name)
config.value = value
config.save!
puts " 💾 Updated #{name}#{value}"
end
def clear_cache
GlobalConfig.clear_cache
puts ' 🗑️ Cleared configuration cache'
end
end
end
# rubocop:enable Metrics/BlockLength

View File

@@ -0,0 +1,183 @@
# Download Report Rake Tasks
#
# Usage:
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:agent
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:inbox
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:label
#
# The task will prompt for:
# - Account ID
# - Start Date (YYYY-MM-DD)
# - End Date (YYYY-MM-DD)
# - Timezone Offset (e.g., 0, 5.5, -5)
# - Business Hours (y/n) - whether to use business hours for time metrics
#
# Output: <account_id>_<type>_<start_date>_<end_date>.csv
require 'csv'
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/ModuleLength
module DownloadReportTasks
def self.prompt(message)
print "#{message}: "
$stdin.gets.chomp
end
def self.collect_params
account_id = prompt('Enter Account ID')
abort 'Error: Account ID is required' if account_id.blank?
account = Account.find_by(id: account_id)
abort "Error: Account with ID '#{account_id}' not found" unless account
start_date = prompt('Enter Start Date (YYYY-MM-DD)')
abort 'Error: Start date is required' if start_date.blank?
end_date = prompt('Enter End Date (YYYY-MM-DD)')
abort 'Error: End date is required' if end_date.blank?
timezone_offset = prompt('Enter Timezone Offset (e.g., 0, 5.5, -5)')
timezone_offset = timezone_offset.blank? ? 0 : timezone_offset.to_f
business_hours = prompt('Use Business Hours? (y/n)')
business_hours = business_hours.downcase == 'y'
begin
tz = ActiveSupport::TimeZone[timezone_offset]
abort "Error: Invalid timezone offset '#{timezone_offset}'" unless tz
since = tz.parse("#{start_date} 00:00:00").to_i.to_s
until_date = tz.parse("#{end_date} 23:59:59").to_i.to_s
rescue StandardError => e
abort "Error parsing dates: #{e.message}"
end
{
account: account,
params: { since: since, until: until_date, timezone_offset: timezone_offset, business_hours: business_hours },
start_date: start_date,
end_date: end_date
}
end
def self.save_csv(filename, headers, rows)
CSV.open(filename, 'w') do |csv|
csv << headers
rows.each { |row| csv << row }
end
puts "Report saved to: #{filename}"
end
def self.format_time(seconds)
return '' if seconds.nil? || seconds.zero?
seconds.round(2)
end
def self.download_agent_report
data = collect_params
account = data[:account]
puts "\nGenerating agent report..."
builder = V2::Reports::AgentSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
users = account.users.index_by(&:id)
headers = %w[id name email conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
user = users[row[:id]]
[
row[:id],
user&.name || 'Unknown',
user&.email || 'Unknown',
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_agent_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
def self.download_inbox_report
data = collect_params
account = data[:account]
puts "\nGenerating inbox report..."
builder = V2::Reports::InboxSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
inboxes = account.inboxes.index_by(&:id)
headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
inbox = inboxes[row[:id]]
[
row[:id],
inbox&.name || 'Unknown',
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_inbox_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
def self.download_label_report
data = collect_params
account = data[:account]
puts "\nGenerating label report..."
builder = V2::Reports::LabelSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
[
row[:id],
row[:name],
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_label_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
end
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/ModuleLength
namespace :download_report do
desc 'Download agent summary report as CSV'
task agent: :environment do
DownloadReportTasks.download_agent_report
end
desc 'Download inbox summary report as CSV'
task inbox: :environment do
DownloadReportTasks.download_inbox_report
end
desc 'Download label summary report as CSV'
task label: :environment do
DownloadReportTasks.download_label_report
end
end

View File

@@ -0,0 +1,30 @@
require_relative '../test_data'
namespace :data do
desc 'Generate large, distributed test data'
task generate_distributed_data: :environment do
if Rails.env.production?
puts 'Generating large amounts of data in production can have serious performance implications.'
puts 'Exiting to avoid impacting a live environment.'
exit
end
# Configure logger
Rails.logger = ActiveSupport::Logger.new($stdout)
Rails.logger.formatter = proc do |severity, datetime, _progname, msg|
"#{datetime.strftime('%Y-%m-%d %H:%M:%S.%L')} #{severity}: #{msg}\n"
end
begin
TestData::DatabaseOptimizer.setup
TestData.generate
ensure
TestData::DatabaseOptimizer.restore
end
end
desc 'Clean up existing test data'
task cleanup_test_data: :environment do
TestData.cleanup
end
end

View File

@@ -0,0 +1,8 @@
namespace :instance_id do
desc 'Get the installation identifier'
task :get_installation_identifier => :environment do
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
identifier ||= InstallationConfig.create!(name: 'INSTALLATION_IDENTIFIER', value: SecureRandom.uuid).value
puts identifier
end
end

View File

@@ -0,0 +1,7 @@
require 'rubygems/package'
namespace :ip_lookup do
task setup: :environment do
Geocoder::SetupService.new.perform
end
end

View File

@@ -0,0 +1,65 @@
module MfaTasks
def self.find_user_or_exit(email)
abort 'Error: Please provide an email address' if email.blank?
user = User.from_email(email)
abort "Error: User with email '#{email}' not found" unless user
user
end
def self.reset_user_mfa(user)
user.update!(
otp_required_for_login: false,
otp_secret: nil,
otp_backup_codes: nil
)
end
def self.reset_single(args)
user = find_user_or_exit(args[:email])
abort "MFA is already disabled for #{args[:email]}" if !user.otp_required_for_login? && user.otp_secret.nil?
reset_user_mfa(user)
puts "✓ MFA has been successfully reset for #{args[:email]}"
rescue StandardError => e
abort "Error resetting MFA: #{e.message}"
end
def self.reset_all
print 'Are you sure you want to reset MFA for ALL users? This cannot be undone! (yes/no): '
abort 'Operation cancelled' unless $stdin.gets.chomp.downcase == 'yes'
affected_users = User.where(otp_required_for_login: true).or(User.where.not(otp_secret: nil))
count = affected_users.count
abort 'No users have MFA enabled' if count.zero?
puts "\nResetting MFA for #{count} user(s)..."
affected_users.find_each { |user| reset_user_mfa(user) }
puts "✓ MFA has been reset for #{count} user(s)"
end
def self.generate_backup_codes(args)
user = find_user_or_exit(args[:email])
abort "Error: MFA is not enabled for #{args[:email]}" unless user.otp_required_for_login?
service = Mfa::ManagementService.new(user: user)
codes = service.generate_backup_codes!
puts "\nNew backup codes generated for #{args[:email]}:"
codes.each { |code| puts code }
end
end
namespace :mfa do
desc 'Reset MFA for a specific user by email'
task :reset, [:email] => :environment do |_task, args|
MfaTasks.reset_single(args)
end
desc 'Reset MFA for all users in the system'
task reset_all: :environment do
MfaTasks.reset_all
end
desc 'Generate new backup codes for a user'
task :generate_backup_codes, [:email] => :environment do |_task, args|
MfaTasks.generate_backup_codes(args)
end
end

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
# Run with:
# bundle exec rake chatwoot:ops:cleanup_orphan_conversations
namespace :chatwoot do
namespace :ops do
desc 'Identify and delete conversations without a valid contact or inbox in a timeframe'
task cleanup_orphan_conversations: :environment do
print 'Enter Account ID: '
account_id = $stdin.gets.to_i
account = Account.find(account_id)
print 'Enter timeframe in days (default: 7): '
days_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i
service = Internal::RemoveOrphanConversationsService.new(account: account, days: days)
# Preview count using the same query logic
base = account
.conversations
.where('conversations.created_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox)
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
count = conversations.count
puts "Found #{count} conversations without a valid contact or inbox."
if count.positive?
print 'Do you want to delete these conversations? (y/N): '
confirm = $stdin.gets.strip.downcase
if %w[y yes].include?(confirm)
total_deleted = service.perform
puts "#{total_deleted} conversations deleted."
else
puts 'No conversations were deleted.'
end
end
end
end
end

View File

@@ -0,0 +1,183 @@
# rubocop:disable Metrics/BlockLength
namespace :search do
desc 'Create test messages for advanced search manual testing across multiple inboxes'
task setup_test_data: :environment do
puts '🔍 Setting up test data for advanced search...'
account = Account.first
unless account
puts '❌ No account found. Please create an account first.'
exit 1
end
agents = account.users.to_a
unless agents.any?
puts '❌ No agents found. Please create users first.'
exit 1
end
puts "✅ Using account: #{account.name} (ID: #{account.id})"
puts "✅ Found #{agents.count} agent(s)"
# Create missing inbox types for comprehensive testing
puts "\n📥 Checking and creating inboxes..."
# API inbox
unless account.inboxes.exists?(channel_type: 'Channel::Api')
puts ' Creating API inbox...'
account.inboxes.create!(
name: 'Search Test API',
channel: Channel::Api.create!(account: account)
)
end
# Web Widget inbox
unless account.inboxes.exists?(channel_type: 'Channel::WebWidget')
puts ' Creating WebWidget inbox...'
account.inboxes.create!(
name: 'Search Test WebWidget',
channel: Channel::WebWidget.create!(account: account, website_url: 'https://example.com')
)
end
# Email inbox
unless account.inboxes.exists?(channel_type: 'Channel::Email')
puts ' Creating Email inbox...'
account.inboxes.create!(
name: 'Search Test Email',
channel: Channel::Email.create!(
account: account,
email: 'search-test@example.com',
imap_enabled: false,
smtp_enabled: false
)
)
end
inboxes = account.inboxes.to_a
puts "✅ Using #{inboxes.count} inbox(es):"
inboxes.each { |i| puts " - #{i.name} (ID: #{i.id}, Type: #{i.channel_type})" }
# Create 10 test contacts
contacts = []
10.times do |i|
contacts << account.contacts.find_or_create_by!(
email: "test-customer-#{i}@example.com"
) do |c|
c.name = Faker::Name.name
end
end
puts "✅ Created/found #{contacts.count} test contacts"
target_messages = 50_000
messages_per_conversation = 100
total_conversations = target_messages / messages_per_conversation
puts "\n📝 Creating #{target_messages} messages across #{total_conversations} conversations..."
puts " Distribution: #{inboxes.count} inboxes × #{total_conversations / inboxes.count} conversations each"
start_time = 2.years.ago
end_time = Time.current
time_range = end_time - start_time
created_count = 0
failed_count = 0
conversations_per_inbox = total_conversations / inboxes.count
conversation_statuses = [:open, :resolved]
inboxes.each do |inbox|
conversations_per_inbox.times do
# Pick random contact and agent for this conversation
contact = contacts.sample
agent = agents.sample
# Create or find ContactInbox
contact_inbox = ContactInbox.find_or_create_by!(
contact: contact,
inbox: inbox
) do |ci|
ci.source_id = "test_#{SecureRandom.hex(8)}"
end
# Create conversation
conversation = inbox.conversations.create!(
account: account,
contact: contact,
inbox: inbox,
contact_inbox: contact_inbox,
status: conversation_statuses.sample
)
# Create messages for this conversation (50 incoming, 50 outgoing)
50.times do
random_time = start_time + (rand * time_range)
# Incoming message from contact
begin
Message.create!(
content: Faker::Movie.quote,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
sender: contact,
created_at: random_time,
updated_at: random_time
)
created_count += 1
rescue StandardError => e
failed_count += 1
puts "❌ Failed to create message: #{e.message}" if failed_count <= 5
end
# Outgoing message from agent
begin
Message.create!(
content: Faker::Movie.quote,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :outgoing,
sender: agent,
created_at: random_time + rand(60..600),
updated_at: random_time + rand(60..600)
)
created_count += 1
rescue StandardError => e
failed_count += 1
puts "❌ Failed to create message: #{e.message}" if failed_count <= 5
end
print "\r🔄 Progress: #{created_count}/#{target_messages} messages created..." if (created_count % 500).zero?
end
end
end
puts "\n\n✅ Successfully created #{created_count} messages!"
puts "❌ Failed: #{failed_count}" if failed_count.positive?
puts "\n📊 Summary:"
puts " - Total messages: #{Message.where(account: account).count}"
puts " - Total conversations: #{Conversation.where(account: account).count}"
min_date = Message.where(account: account).minimum(:created_at)&.strftime('%Y-%m-%d')
max_date = Message.where(account: account).maximum(:created_at)&.strftime('%Y-%m-%d')
puts " - Date range: #{min_date} to #{max_date}"
puts "\nBreakdown by inbox:"
inboxes.each do |inbox|
msg_count = Message.where(inbox: inbox).count
conv_count = Conversation.where(inbox: inbox).count
puts " - #{inbox.name} (#{inbox.channel_type}): #{msg_count} messages, #{conv_count} conversations"
end
puts "\nBreakdown by sender type:"
puts " - Incoming (from contacts): #{Message.where(account: account, message_type: :incoming).count}"
puts " - Outgoing (from agents): #{Message.where(account: account, message_type: :outgoing).count}"
puts "\n🔧 Next steps:"
puts ' 1. Ensure OpenSearch is running: mise elasticsearch-start'
puts ' 2. Reindex messages: rails runner "Message.search_index.import Message.all"'
puts " 3. Enable feature: rails runner \"Account.find(#{account.id}).enable_features('advanced_search')\""
puts "\n💡 Then test the search with filters via API or Rails console!"
end
end
# rubocop:enable Metrics/BlockLength

View File

@@ -0,0 +1,24 @@
namespace :db do
namespace :seed do
desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
task reports_data: :environment do
if ENV['ACCOUNT_ID'].blank?
puts 'Please provide an ACCOUNT_ID environment variable'
puts 'Usage: ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data'
exit 1
end
ENV['ENABLE_ACCOUNT_SEEDING'] = 'true' if ENV['ENABLE_ACCOUNT_SEEDING'].blank?
account_id = ENV.fetch('ACCOUNT_ID', nil)
account = Account.find(account_id)
puts "Starting reports data seeding for account: #{account.name} (ID: #{account.id})"
seeder = Seeders::Reports::ReportDataSeeder.new(account: account)
seeder.perform!
puts "Finished seeding reports data for account: #{account.name}"
end
end
end

View File

@@ -0,0 +1,17 @@
namespace :sidekiq do
desc "Clear ActionCableJobs from sidekiq's critical queue"
task clear_action_cable_broadcast_jobs: :environment do
queue_name = 'critical'
queue = Sidekiq::Queue.new(queue_name)
jobs_cleared = 0
queue.each do |job|
if job['wrapped'] == 'ActionCableBroadcastJob'
job.delete
jobs_cleared += 1
end
end
puts "Cleared #{jobs_cleared} ActionCableBroadcastJob(s) from the #{queue_name} queue."
end
end

View File

@@ -0,0 +1,156 @@
require 'json_refs'
require 'fileutils'
require 'pathname'
require 'yaml'
require 'json'
module SwaggerTaskActions
def self.execute_build
swagger_dir = Rails.root.join('swagger')
# Paths relative to swagger_dir for use within Dir.chdir
index_yml_relative_path = 'index.yml'
swagger_json_relative_path = 'swagger.json'
Dir.chdir(swagger_dir) do
# Operations within this block are relative to swagger_dir
swagger_index_content = File.read(index_yml_relative_path)
swagger_index = YAML.safe_load(swagger_index_content)
final_build = JsonRefs.call(
swagger_index,
resolve_local_ref: false,
resolve_file_ref: true, # Uses CWD (swagger_dir) for resolving file refs
logging: true
)
File.write(swagger_json_relative_path, JSON.pretty_generate(final_build))
# For user messages, provide the absolute path
absolute_swagger_json_path = swagger_dir.join(swagger_json_relative_path)
puts 'Swagger build was successful.'
puts "Generated #{absolute_swagger_json_path}"
puts 'Go to http://localhost:3000/swagger see the changes.'
# Trigger dependent task
Rake::Task['swagger:build_tag_groups'].invoke
end
end
def self.execute_build_tag_groups
base_swagger_path = Rails.root.join('swagger')
tag_groups_output_dir = base_swagger_path.join('tag_groups')
full_spec_path = base_swagger_path.join('swagger.json')
index_yml_path = base_swagger_path.join('index.yml')
full_spec = JSON.parse(File.read(full_spec_path))
swagger_index = YAML.safe_load(File.read(index_yml_path))
tag_groups = swagger_index['x-tagGroups']
FileUtils.mkdir_p(tag_groups_output_dir)
tag_groups.each do |tag_group|
_process_tag_group(tag_group, full_spec, tag_groups_output_dir)
end
puts 'Tag-specific swagger files generated successfully.'
end
def self.execute_build_for_docs
Rake::Task['swagger:build'].invoke # Ensure all swagger files are built first
developer_docs_public_path = Rails.root.join('developer-docs/public')
tag_groups_in_dev_docs_path = developer_docs_public_path.join('swagger/tag_groups')
source_tag_groups_path = Rails.root.join('swagger/tag_groups')
FileUtils.mkdir_p(tag_groups_in_dev_docs_path)
puts 'Creating symlinks for developer-docs...'
symlink_files = %w[platform_swagger.json application_swagger.json client_swagger.json other_swagger.json]
symlink_files.each do |file|
_create_symlink(source_tag_groups_path.join(file), tag_groups_in_dev_docs_path.join(file))
end
puts 'Symlinks created successfully.'
puts 'You can now run the Mintlify dev server to preview the documentation.'
end
# Private helper methods
class << self
private
def _process_tag_group(tag_group, full_spec, output_dir)
group_name = tag_group['name']
tags_in_current_group = tag_group['tags']
tag_spec = JSON.parse(JSON.generate(full_spec)) # Deep clone
tag_spec['paths'] = _filter_paths_for_tag_group(tag_spec['paths'], tags_in_current_group)
tag_spec['tags'] = _filter_tags_for_tag_group(tag_spec['tags'], tags_in_current_group)
output_filename = _determine_output_filename(group_name)
File.write(output_dir.join(output_filename), JSON.pretty_generate(tag_spec))
end
def _operation_has_matching_tags?(operation, tags_in_group)
return false unless operation.is_a?(Hash)
operation_tags = operation['tags']
return false unless operation_tags.is_a?(Array)
operation_tags.intersect?(tags_in_group)
end
def _filter_paths_for_tag_group(paths_spec, tags_in_group)
(paths_spec || {}).filter_map do |path, path_item|
next unless path_item.is_a?(Hash)
operations_with_group_tags = path_item.any? do |_method, operation|
_operation_has_matching_tags?(operation, tags_in_group)
end
[path, path_item] if operations_with_group_tags
end.to_h
end
def _filter_tags_for_tag_group(tags_spec, tags_in_group)
if tags_spec.is_a?(Array)
tags_spec.select { |tag_definition| tags_in_group.include?(tag_definition['name']) }
else
[]
end
end
def _determine_output_filename(group_name)
return 'other_swagger.json' if group_name.casecmp('others').zero?
sanitized_group_name = group_name.downcase.tr(' ', '_').gsub(/[^a-z0-9_]+/, '')
"#{sanitized_group_name}_swagger.json"
end
def _create_symlink(source_file_path, target_file_path)
FileUtils.rm_f(target_file_path) # Remove existing to avoid errors
if File.exist?(source_file_path)
relative_source_path = Pathname.new(source_file_path).relative_path_from(target_file_path.dirname)
FileUtils.ln_sf(relative_source_path, target_file_path)
else
puts "Warning: Source file #{source_file_path} not found. Skipping symlink for #{File.basename(target_file_path)}."
end
end
end
end
namespace :swagger do
desc 'build combined swagger.json file from all the fragmented definitions and paths inside swagger folder'
task build: :environment do
SwaggerTaskActions.execute_build
end
desc 'build separate swagger files for each tag group'
task build_tag_groups: :environment do
SwaggerTaskActions.execute_build_tag_groups
end
desc 'build swagger files and create symlinks in developer-docs'
task build_for_docs: :environment do
SwaggerTaskActions.execute_build_for_docs
end
end

Some files were not shown because too many files have changed in this diff Show More