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