Merge branch 'develop' into feature/#7907-script-to-change-local-storage-to-aws
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
def image(node)
|
||||
src, title = extract_img_attributes(node)
|
||||
height = extract_image_height(src)
|
||||
sizing_style = extract_image_sizing_style(src)
|
||||
|
||||
render_img_tag(src, title, height)
|
||||
render_img_tag(src, title, sizing_style)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -15,9 +15,25 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
]
|
||||
end
|
||||
|
||||
def extract_image_height(src)
|
||||
# Drag-resize from the reply editor encodes the chosen width as cw_image_width
|
||||
# on the URL; the older message-signature picker uses cw_image_height. Width
|
||||
# wins when both are set so the agent's most recent intent is honored.
|
||||
def extract_image_sizing_style(src)
|
||||
query_params = parse_query_params(src)
|
||||
query_params['cw_image_height']&.first
|
||||
width = sanitize_pixel_value(query_params['cw_image_width']&.first)
|
||||
return "width: #{width}; max-width: 100%; height: auto;" if width
|
||||
|
||||
height = sanitize_pixel_value(query_params['cw_image_height']&.first)
|
||||
height ? "height: #{height};" : nil
|
||||
end
|
||||
|
||||
# Only allow a bounded `<digits>px` value so the decoded query param can't
|
||||
# break out of the inline style attribute (HTML attribute injection).
|
||||
def sanitize_pixel_value(raw)
|
||||
return unless raw =~ /\A(\d+)px\z/
|
||||
|
||||
px = Regexp.last_match(1).to_i
|
||||
"#{px}px" if px.between?(1, 2000)
|
||||
end
|
||||
|
||||
def parse_query_params(url)
|
||||
@@ -27,13 +43,17 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
{}
|
||||
end
|
||||
|
||||
def render_img_tag(src, title, height = nil)
|
||||
def render_img_tag(src, title, sizing_style = nil)
|
||||
title_attribute = title.present? ? " title=\"#{title}\"" : ''
|
||||
height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : ''
|
||||
# Use inline style instead of HTML width/height attributes: email clients
|
||||
# and the in-app Letter view both run images through CSS (e.g. prose /
|
||||
# lettersanitizer's `img { height: auto }`) which overrides presentational
|
||||
# attributes. Inline style has higher specificity and survives.
|
||||
style_attribute = sizing_style ? " style=\"#{sizing_style}\"" : ''
|
||||
|
||||
plain do
|
||||
# plain ensures that the content is not wrapped in a paragraph tag
|
||||
out("<img src=\"#{src}\"#{title_attribute}#{height_attribute} />")
|
||||
out("<img src=\"#{src}\"#{title_attribute}#{style_attribute} />")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
class Captain::BaseTaskService
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::ToolInstrumentation
|
||||
include Llm::ExceptionTrackable
|
||||
|
||||
# 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:, schema: nil, 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, schema: schema, 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:, schema: nil, tools: [])
|
||||
credential = llm_credential
|
||||
|
||||
Llm::Config.with_api_key(credential[:api_key], api_base: api_base) do |context|
|
||||
chat = build_chat(context, model: model, messages: messages, schema: schema, 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
|
||||
capture_llm_exception(e, credential: credential)
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_chat(context, model:, messages:, schema: nil, tools: [])
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
chat.with_schema(schema) if schema
|
||||
|
||||
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
|
||||
next if content.blank?
|
||||
break if 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
|
||||
|
||||
# Extension point consulted by the Enterprise quota wrapper. Subclasses
|
||||
# whose calls should not consume captain_responses should override this to
|
||||
# return false. When false, the wrapper neither blocks the call on an
|
||||
# exhausted captain_responses quota nor decrements it on success — the call
|
||||
# participates in the quota system in neither direction.
|
||||
def counts_toward_usage?
|
||||
llm_credential&.dig(:source) != :hook
|
||||
end
|
||||
|
||||
def api_key_configured?
|
||||
llm_credential.present?
|
||||
end
|
||||
|
||||
def api_key
|
||||
llm_credential&.dig(:api_key)
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= if use_account_openai_hook?
|
||||
hook_llm_credential || system_llm_credential
|
||||
else
|
||||
system_llm_credential
|
||||
end
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
false
|
||||
end
|
||||
|
||||
def hook_llm_credential
|
||||
key = openai_hook&.settings&.dig('api_key').presence
|
||||
{ api_key: key, source: :hook } if key
|
||||
end
|
||||
|
||||
def system_llm_credential
|
||||
{ api_key: system_api_key, source: :system } if system_api_key.present?
|
||||
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 exception_tracking_account
|
||||
account
|
||||
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')
|
||||
@@ -0,0 +1,70 @@
|
||||
class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :message!, { button_text: nil, language: 'en', baseline: {} }]
|
||||
|
||||
def perform
|
||||
api_response = make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: message }
|
||||
]
|
||||
)
|
||||
|
||||
return api_response if api_response[:error]
|
||||
|
||||
build_result(api_response[:message])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_result(response_message)
|
||||
parsed = parse_json_response(response_message)
|
||||
return { error: 'Invalid LLM response format' } if parsed.blank?
|
||||
|
||||
core_result(parsed).merge(message: response_message)
|
||||
end
|
||||
|
||||
def core_result(parsed)
|
||||
{
|
||||
classification: normalize_classification(parsed['classification']),
|
||||
optimized_message: parsed['optimized_message'].presence || baseline[:optimized_message]
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
template = prompt_from_file('csat_utility_analysis')
|
||||
Liquid::Template.parse(template).render(prompt_variables)
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
{
|
||||
'message' => message.to_s,
|
||||
'button_text' => button_text.to_s,
|
||||
'language' => language.to_s,
|
||||
'baseline_classification' => baseline[:classification].to_s
|
||||
}
|
||||
end
|
||||
|
||||
def parse_json_response(content)
|
||||
raw = content.to_s.strip
|
||||
json = raw.match(/```json\s*(.*?)\s*```/m)&.captures&.first || raw
|
||||
JSON.parse(json)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_classification(value)
|
||||
normalized = value.to_s.upcase
|
||||
return normalized if %w[LIKELY_UTILITY LIKELY_MARKETING UNCLEAR].include?(normalized)
|
||||
|
||||
baseline[:classification].presence || 'UNCLEAR'
|
||||
end
|
||||
|
||||
def event_name
|
||||
'csat_utility_analysis'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
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
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
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 use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
|
||||
@@ -0,0 +1,63 @@
|
||||
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
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
class Captain::SummaryService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
#{prompt_from_file('summary')}
|
||||
|
||||
Reply in #{account.locale_english_name}.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def event_name
|
||||
'summarize'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
with_propagated_langfuse_attributes(params) do
|
||||
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
|
||||
end
|
||||
response
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
executed ? response : yield
|
||||
end
|
||||
|
||||
def set_tool_session_attributes(span, params)
|
||||
set_metadata_attributes(span, params)
|
||||
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|
|
||||
apply_current_langfuse_attributes(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
|
||||
@@ -17,10 +17,22 @@ module ChatwootApp
|
||||
@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]
|
||||
@@ -30,4 +42,15 @@ module ChatwootApp
|
||||
%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
|
||||
|
||||
+53
-19
@@ -1,10 +1,30 @@
|
||||
# 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
|
||||
DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
|
||||
|
||||
def self.base_url
|
||||
DEFAULT_BASE_URL
|
||||
end
|
||||
|
||||
def self.ping_url
|
||||
"#{base_url}/ping"
|
||||
end
|
||||
|
||||
def self.registration_url
|
||||
"#{base_url}/instances"
|
||||
end
|
||||
|
||||
def self.push_notification_url
|
||||
"#{base_url}/send_push"
|
||||
end
|
||||
|
||||
def self.events_url
|
||||
"#{base_url}/events"
|
||||
end
|
||||
|
||||
def self.billing_base_url
|
||||
"#{base_url}/billing"
|
||||
end
|
||||
|
||||
def self.installation_identifier
|
||||
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
|
||||
@@ -13,14 +33,18 @@ class ChatwootHub
|
||||
end
|
||||
|
||||
def self.billing_url
|
||||
"#{BILLING_URL}?installation_identifier=#{installation_identifier}"
|
||||
"#{billing_base_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
|
||||
|
||||
@@ -44,21 +68,25 @@ class ChatwootHub
|
||||
|
||||
def self.instance_metrics
|
||||
{
|
||||
accounts_count: Account.count,
|
||||
users_count: User.count,
|
||||
inboxes_count: Inbox.count,
|
||||
conversations_count: Conversation.count,
|
||||
incoming_messages_count: Message.incoming.count,
|
||||
outgoing_messages_count: Message.outgoing.count,
|
||||
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 })
|
||||
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}"
|
||||
@@ -70,30 +98,36 @@ class ChatwootHub
|
||||
|
||||
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 })
|
||||
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_browser_push(fcm_token_list, fcm_options)
|
||||
info = { fcm_token_list: fcm_token_list, fcm_options: fcm_options }
|
||||
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
def self.send_push(fcm_options)
|
||||
send_push_with_response(fcm_options)
|
||||
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_with_response(fcm_options)
|
||||
info = { fcm_options: fcm_options }
|
||||
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :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 })
|
||||
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
|
||||
|
||||
ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub')
|
||||
|
||||
@@ -3,16 +3,16 @@ class ChatwootMarkdownRenderer
|
||||
@content = content
|
||||
end
|
||||
|
||||
def render_message
|
||||
markdown_renderer = BaseMarkdownRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT)
|
||||
def render_message(hardbreaks: false)
|
||||
markdown_renderer = BaseMarkdownRenderer.new(options: hardbreaks ? [:HARDBREAKS] : :DEFAULT)
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough, :autolink])
|
||||
html = markdown_renderer.render(doc)
|
||||
render_as_html_safe(html)
|
||||
end
|
||||
|
||||
def render_article
|
||||
markdown_renderer = CustomMarkdownRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT)
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:table])
|
||||
html = markdown_renderer.render(doc)
|
||||
|
||||
render_as_html_safe(html)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
class CsmlEngine
|
||||
API_KEY_HEADER = 'X-Api-Key'.freeze
|
||||
|
||||
def initialize
|
||||
@host_url = GlobalConfigService.load('CSML_BOT_HOST', '')
|
||||
@api_key = GlobalConfigService.load('CSML_BOT_API_KEY', '')
|
||||
|
||||
raise ArgumentError, 'Missing Credentials' if @host_url.blank? || @api_key.blank?
|
||||
end
|
||||
|
||||
def status
|
||||
response = HTTParty.get("#{@host_url}/status")
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def run(bot, params)
|
||||
payload = {
|
||||
bot: bot,
|
||||
event: {
|
||||
request_id: SecureRandom.uuid,
|
||||
client: params[:client],
|
||||
payload: params[:payload],
|
||||
metadata: params[:metadata],
|
||||
ttl_duration: 4000
|
||||
}
|
||||
}
|
||||
response = post('run', payload)
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def validate(bot)
|
||||
response = post('validate', bot)
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_response(response)
|
||||
return response.parsed_response if response.success?
|
||||
|
||||
{ error: response.parsed_response, status: response.code }
|
||||
end
|
||||
|
||||
def post(path, payload)
|
||||
HTTParty.post(
|
||||
"#{@host_url}/#{path}", {
|
||||
headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' },
|
||||
body: payload.to_json
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,9 @@
|
||||
module CustomExceptions::Account
|
||||
class InvalidEmail < CustomExceptions::Base
|
||||
def message
|
||||
if @data[:disposable]
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
|
||||
end
|
||||
|
||||
def http_status
|
||||
409
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,12 @@ module CustomExceptions::CustomFilter
|
||||
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])
|
||||
|
||||
@@ -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
|
||||
+142
-56
@@ -1,7 +1,42 @@
|
||||
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
|
||||
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
|
||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
||||
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
|
||||
|
||||
# Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
|
||||
# so cells without an explicit colwidth render the same minimum here as in the editor.
|
||||
TABLE_CELL_MIN_WIDTH_PX = 50
|
||||
COLWIDTHS_COMMENT = /<!--cw-colwidths:([\d,]+)-->/
|
||||
|
||||
# The article editor serializes column widths as a `<!--cw-colwidths:...-->` HTML
|
||||
# comment immediately before each resized table. Capture it (emitting nothing) so the
|
||||
# next `table` can size itself; any other raw HTML keeps its default rendering.
|
||||
def html(node)
|
||||
match = node.string_content.match(COLWIDTHS_COMMENT)
|
||||
return super unless match
|
||||
|
||||
@pending_colwidths = match[1].split(',').map(&:to_i)
|
||||
end
|
||||
|
||||
def table(node)
|
||||
widths = @pending_colwidths
|
||||
@pending_colwidths = nil
|
||||
|
||||
if sized_widths?(widths)
|
||||
out(table_wrapper_open(widths))
|
||||
out(inject_table_sizing(capture_html { super(node) }, widths))
|
||||
else
|
||||
out('<div class="tableWrapper">')
|
||||
super
|
||||
end
|
||||
out('</div>')
|
||||
end
|
||||
|
||||
def text(node)
|
||||
content = node.string_content
|
||||
@@ -17,12 +52,90 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
def link(node)
|
||||
return if surrounded_by_empty_lines?(node) && render_embedded_content(node)
|
||||
|
||||
# If it's not YouTube or Vimeo link, render normally
|
||||
# If it's not a supported embed link, render normally
|
||||
super
|
||||
end
|
||||
|
||||
def image(node)
|
||||
src = escape_href(node.url)
|
||||
width = extract_image_width(src)
|
||||
plain do
|
||||
out(%(<img src="#{src}"))
|
||||
out(' alt="', :children, '"')
|
||||
out(%( title="#{escape_html(node.title)}")) if node.title.present?
|
||||
out(%( style="width: #{width}; max-width: 100%; height: auto;")) if width
|
||||
out(' />')
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sized_widths?(widths)
|
||||
widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
|
||||
end
|
||||
|
||||
def fully_sized?(widths)
|
||||
widths.all? { |w| w.to_i.positive? }
|
||||
end
|
||||
|
||||
# Fully-sized tables hug their exact width so the card doesn't trail empty space;
|
||||
# partial tables stay a plain full-width card so flexible columns can expand.
|
||||
def table_wrapper_open(widths)
|
||||
return '<div class="tableWrapper">' unless fully_sized?(widths)
|
||||
|
||||
%(<div class="tableWrapper" style="width: #{total_width(widths)}px; max-width: 100%;">)
|
||||
end
|
||||
|
||||
# Let the gem render the whole table, then splice a <colgroup> and sizing style
|
||||
# into the opening <table> tag. Delegating the row/cell/tbody/alignment markup to
|
||||
# super keeps this working across commonmarker upgrades.
|
||||
# `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
|
||||
def inject_table_sizing(html, widths)
|
||||
opening = %(<table style="#{table_sizing_style(widths)}">\n#{colgroup_html(widths)})
|
||||
html.sub(/<table[^>]*>\n?/, opening)
|
||||
end
|
||||
|
||||
# Capture everything `super` writes by swapping the renderer's output buffer.
|
||||
def capture_html
|
||||
original = @stream
|
||||
@stream = StringIO.new(+'')
|
||||
yield
|
||||
@stream.string
|
||||
ensure
|
||||
@stream = original
|
||||
end
|
||||
|
||||
# Total table width: each column's saved width, or the cell min for unsized ones.
|
||||
def total_width(widths)
|
||||
widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
|
||||
end
|
||||
|
||||
# Fully sized → lock to the exact total (min-width too, so a narrow saved width
|
||||
# beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
|
||||
# the container (flexible columns) yet scrolls when the sized columns exceed it.
|
||||
def table_sizing_style(widths)
|
||||
total = total_width(widths)
|
||||
return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
|
||||
|
||||
"table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
|
||||
end
|
||||
|
||||
def colgroup_html(widths)
|
||||
cols = widths.map { |w| w.to_i.positive? ? %(<col style="width: #{w.to_i}px;">) : '<col>' }
|
||||
"<colgroup>#{cols.join}</colgroup>\n"
|
||||
end
|
||||
|
||||
def extract_image_width(src)
|
||||
query = URI.parse(src).query
|
||||
raw = query && CGI.parse(query)['cw_image_width']&.first
|
||||
return unless raw =~ /\A(\d+)px\z/
|
||||
|
||||
px = Regexp.last_match(1).to_i
|
||||
"#{px}px" if px.between?(1, 2000)
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def surrounded_by_empty_lines?(node)
|
||||
prev_node_empty?(node.previous) && next_node_empty?(node.next)
|
||||
end
|
||||
@@ -41,26 +154,36 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
|
||||
def render_embedded_content(node)
|
||||
link_url = node.url
|
||||
embed_html = find_matching_embed(link_url)
|
||||
|
||||
youtube_match = link_url.match(YOUTUBE_REGEX)
|
||||
if youtube_match
|
||||
out(make_youtube_embed(youtube_match))
|
||||
return true
|
||||
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
|
||||
|
||||
vimeo_match = link_url.match(VIMEO_REGEX)
|
||||
if vimeo_match
|
||||
out(make_vimeo_embed(vimeo_match))
|
||||
return true
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
mp4_match = link_url.match(MP4_REGEX)
|
||||
if mp4_match
|
||||
out(make_video_embed(link_url))
|
||||
return true
|
||||
end
|
||||
def render_embed_from_match(embed_key, match_data)
|
||||
embed_config = self.class.config[embed_key]
|
||||
return nil unless embed_config
|
||||
|
||||
false
|
||||
template = embed_config['template']
|
||||
# Use gsub (not format) so CSS `%` values in templates don't need escaping.
|
||||
# Captured values are HTML-escaped since they land inside HTML attribute contexts.
|
||||
match_data.named_captures.each do |var_name, value|
|
||||
template = template.gsub("%{#{var_name}}", CGI.escapeHTML(value))
|
||||
end
|
||||
template
|
||||
end
|
||||
|
||||
def parse_sup(content)
|
||||
@@ -72,41 +195,4 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def make_youtube_embed(youtube_match)
|
||||
video_id = youtube_match[1]
|
||||
%(
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
)
|
||||
end
|
||||
|
||||
def make_vimeo_embed(vimeo_match)
|
||||
video_id = vimeo_match[1]
|
||||
%(
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/#{video_id}"
|
||||
width="640"
|
||||
height="360"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
)
|
||||
end
|
||||
|
||||
def make_video_embed(link_url)
|
||||
%(
|
||||
<video width="640" height="360" controls>
|
||||
<source src="#{link_url}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
+11
-17
@@ -1,9 +1,10 @@
|
||||
class Dyte
|
||||
BASE_URL = 'https://api.cluster.dyte.in/v1'.freeze
|
||||
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 = 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?
|
||||
@@ -11,15 +12,9 @@ class Dyte
|
||||
|
||||
def create_a_meeting(title)
|
||||
payload = {
|
||||
'title': title,
|
||||
'authorization': {
|
||||
'waitingRoom': false,
|
||||
'closed': false
|
||||
},
|
||||
'recordOnStart': false,
|
||||
'liveStreamOnStart': false
|
||||
'title': title
|
||||
}
|
||||
path = "organizations/#{@organization_id}/meeting"
|
||||
path = 'meetings'
|
||||
response = post(path, payload)
|
||||
process_response(response)
|
||||
end
|
||||
@@ -28,13 +23,12 @@ class Dyte
|
||||
raise ArgumentError, 'Missing information' if meeting_id.blank? || client_id.blank? || name.blank? || avatar_url.blank?
|
||||
|
||||
payload = {
|
||||
'clientSpecificId': client_id.to_s,
|
||||
'userDetails': {
|
||||
'name': name,
|
||||
'picture': avatar_url
|
||||
}
|
||||
'custom_participant_id': client_id.to_s,
|
||||
'name': name,
|
||||
'picture': avatar_url,
|
||||
'preset_name': PRESET_NAME
|
||||
}
|
||||
path = "organizations/#{@organization_id}/meetings/#{meeting_id}/participant"
|
||||
path = "meetings/#{meeting_id}/participants"
|
||||
response = post(path, payload)
|
||||
process_response(response)
|
||||
end
|
||||
@@ -50,7 +44,7 @@ class Dyte
|
||||
def post(path, payload)
|
||||
HTTParty.post(
|
||||
"#{BASE_URL}/#{path}", {
|
||||
headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' },
|
||||
headers: { API_KEY_HEADER => "Basic #{@api_key}", 'Content-Type' => 'application/json' },
|
||||
body: payload.to_json
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,14 +16,18 @@ module Events::Types
|
||||
# conversation events
|
||||
CONVERSATION_CREATED = 'conversation.created'
|
||||
CONVERSATION_UPDATED = 'conversation.updated'
|
||||
CONVERSATION_DELETED = 'conversation.deleted'
|
||||
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_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
|
||||
|
||||
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
|
||||
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
|
||||
CONVERSATION_UNREAD_COUNT_CHANGED = 'conversation.unread_count_changed'
|
||||
ASSIGNEE_CHANGED = 'assignee.changed'
|
||||
TEAM_CHANGED = 'team.changed'
|
||||
CONVERSATION_TYPING_ON = 'conversation.typing_on'
|
||||
@@ -54,4 +58,7 @@ module Events::Types
|
||||
# agent events
|
||||
AGENT_ADDED = 'agent.added'
|
||||
AGENT_REMOVED = 'agent.removed'
|
||||
|
||||
# copilot events
|
||||
COPILOT_MESSAGE_CREATED = 'copilot.message.created'
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb)
|
||||
|
||||
|
||||
# Format
|
||||
# Format
|
||||
# - Parent Key (conversation, contact, messages)
|
||||
# - Key (attribute_name)
|
||||
# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"]
|
||||
@@ -44,6 +44,18 @@ conversations:
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
contact_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
priority:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
display_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "Number"
|
||||
@@ -80,12 +92,6 @@ conversations:
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
country_code:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
referer:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "link"
|
||||
@@ -132,7 +138,7 @@ contacts:
|
||||
- "does_not_contain"
|
||||
phone_number:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
data_type: "text" # Text is not explicity defined in filters, default filter will be used
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
@@ -167,7 +173,7 @@ contacts:
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
company:
|
||||
company_name:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
@@ -214,6 +220,12 @@ messages:
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
private_note:
|
||||
attribute_type: "standard"
|
||||
data_type: "boolean"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
content:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class GlobalConfigService
|
||||
def self.load(config_key, default_value)
|
||||
config = ENV.fetch(config_key) { GlobalConfig.get(config_key)[config_key] }
|
||||
config = GlobalConfig.get(config_key)[config_key]
|
||||
return config if config.present?
|
||||
|
||||
# To support migrating existing instance relying on env variables
|
||||
@@ -14,4 +14,8 @@ class GlobalConfigService
|
||||
GlobalConfig.clear_cache
|
||||
i.value
|
||||
end
|
||||
|
||||
def self.account_signup_enabled?
|
||||
load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Integrations::BotProcessorService
|
||||
# TODO: In CSML processor service, the argument is agent bot, update initializers accordingly.
|
||||
pattr_initialize [:event_name!, :hook!, :event_data!]
|
||||
|
||||
def perform
|
||||
|
||||
@@ -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
|
||||
@@ -1,142 +0,0 @@
|
||||
class Integrations::Csml::ProcessorService < Integrations::BotProcessorService
|
||||
pattr_initialize [:event_name!, :event_data!, :agent_bot!]
|
||||
|
||||
private
|
||||
|
||||
def csml_client
|
||||
@csml_client ||= CsmlEngine.new
|
||||
end
|
||||
|
||||
def get_response(session_id, content)
|
||||
csml_client.run(
|
||||
bot_payload,
|
||||
{
|
||||
client: client_params(session_id),
|
||||
payload: message_payload(content),
|
||||
metadata: metadata_params
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def client_params(session_id)
|
||||
{
|
||||
bot_id: "chatwoot-bot-#{conversation.inbox.id}",
|
||||
channel_id: "chatwoot-bot-inbox-#{conversation.inbox.id}",
|
||||
user_id: session_id
|
||||
}
|
||||
end
|
||||
|
||||
def message_payload(content)
|
||||
{
|
||||
content_type: 'text',
|
||||
content: { text: content }
|
||||
}
|
||||
end
|
||||
|
||||
def metadata_params
|
||||
{
|
||||
conversation: conversation,
|
||||
contact: conversation.contact
|
||||
}
|
||||
end
|
||||
|
||||
def bot_payload
|
||||
{
|
||||
id: "chatwoot-csml-bot-#{agent_bot.id}",
|
||||
name: "chatwoot-csml-bot-#{agent_bot.id}",
|
||||
default_flow: 'chatwoot_bot_flow',
|
||||
flows: [
|
||||
{
|
||||
id: "chatwoot-csml-bot-flow-#{agent_bot.id}-inbox-#{conversation.inbox.id}",
|
||||
name: 'chatwoot_bot_flow',
|
||||
content: agent_bot.bot_config['csml_content'],
|
||||
commands: []
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def process_response(message, response)
|
||||
csml_messages = response['messages']
|
||||
has_conversation_ended = response['conversation_end']
|
||||
|
||||
process_action(message, 'handoff') if has_conversation_ended.present?
|
||||
|
||||
return if csml_messages.blank?
|
||||
|
||||
# We do not support wait, typing now.
|
||||
csml_messages.each do |csml_message|
|
||||
create_messages(csml_message, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def create_messages(message, conversation)
|
||||
message_payload = message['payload']
|
||||
|
||||
case message_payload['content_type']
|
||||
when 'text'
|
||||
process_text_messages(message_payload, conversation)
|
||||
when 'question'
|
||||
process_question_messages(message_payload, conversation)
|
||||
when 'image'
|
||||
process_image_messages(message_payload, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def process_text_messages(message_payload, conversation)
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: message_payload['content']['text'],
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def process_question_messages(message_payload, conversation)
|
||||
buttons = message_payload['content']['buttons'].map do |button|
|
||||
{ title: button['content']['title'], value: button['content']['payload'] }
|
||||
end
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: message_payload['content']['title'],
|
||||
content_type: 'input_select',
|
||||
content_attributes: { items: buttons },
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def prepare_attachment(message_payload, message, account_id)
|
||||
attachment_params = { file_type: :image, account_id: account_id }
|
||||
attachment_url = message_payload['content']['url']
|
||||
attachment = message.attachments.new(attachment_params)
|
||||
attachment_file = Down.download(attachment_url)
|
||||
attachment.file.attach(
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
end
|
||||
|
||||
def process_image_messages(message_payload, conversation)
|
||||
message = conversation.messages.new(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: '',
|
||||
content_type: 'text',
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
|
||||
prepare_attachment(message_payload, message, conversation.account_id)
|
||||
message.save!
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,51 @@
|
||||
require 'google/cloud/dialogflow/v2'
|
||||
|
||||
class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorService
|
||||
SUPPORTED_LANGUAGE_CODES = %w[
|
||||
ar
|
||||
en-US
|
||||
en-GB
|
||||
es-ES
|
||||
es-419
|
||||
fr-FR
|
||||
de-DE
|
||||
pt-BR
|
||||
pt-PT
|
||||
it-IT
|
||||
ja-JP
|
||||
ko-KR
|
||||
zh-CN
|
||||
zh-TW
|
||||
hi-IN
|
||||
ru-RU
|
||||
nl-NL
|
||||
pl-PL
|
||||
tr-TR
|
||||
th-TH
|
||||
vi-VN
|
||||
id-ID
|
||||
].freeze
|
||||
AUTO_LANGUAGE_CODE_MAP = {
|
||||
'ar' => 'ar',
|
||||
'de' => 'de-DE',
|
||||
'en' => 'en-US',
|
||||
'es' => 'es-ES',
|
||||
'fr' => 'fr-FR',
|
||||
'hi' => 'hi-IN',
|
||||
'id' => 'id-ID',
|
||||
'it' => 'it-IT',
|
||||
'ja' => 'ja-JP',
|
||||
'ko' => 'ko-KR',
|
||||
'nl' => 'nl-NL',
|
||||
'pl' => 'pl-PL',
|
||||
'pt' => 'pt-BR',
|
||||
'ru' => 'ru-RU',
|
||||
'th' => 'th-TH',
|
||||
'tr' => 'tr-TR',
|
||||
'vi' => 'vi-VN',
|
||||
'zh' => 'zh-CN'
|
||||
}.freeze
|
||||
|
||||
pattr_initialize [:event_name!, :hook!, :event_data!]
|
||||
|
||||
private
|
||||
@@ -14,13 +59,13 @@ class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorSer
|
||||
message.content
|
||||
end
|
||||
|
||||
def get_response(session_id, message)
|
||||
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)
|
||||
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!
|
||||
@@ -65,13 +110,63 @@ class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorSer
|
||||
::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 = "projects/#{hook.settings['project_id']}/agent/sessions/#{session_id}"
|
||||
query_input = { text: { text: message, language_code: 'en-US' } }
|
||||
session = build_session_path(session_id)
|
||||
query_input = { text: { text: message, language_code: dialogflow_language_code } }
|
||||
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
|
||||
|
||||
def dialogflow_language_code
|
||||
configured_language = hook.settings['language_code'].to_s.strip
|
||||
return 'en-US' if configured_language.blank?
|
||||
return configured_language if configured_language != 'auto'
|
||||
|
||||
normalized_contact_language_code(conversation&.contact&.additional_attributes&.dig('language_code')) || 'en-US'
|
||||
end
|
||||
|
||||
def normalized_contact_language_code(language_code)
|
||||
canonicalized_language_code = canonical_language_code(language_code)
|
||||
return if canonicalized_language_code.blank?
|
||||
return canonicalized_language_code if SUPPORTED_LANGUAGE_CODES.include?(canonicalized_language_code)
|
||||
|
||||
AUTO_LANGUAGE_CODE_MAP[canonicalized_language_code] || AUTO_LANGUAGE_CODE_MAP[canonicalized_language_code.split('-', 2).first]
|
||||
end
|
||||
|
||||
def canonical_language_code(language_code)
|
||||
normalized_language_code = language_code.to_s.tr('_', '-').strip
|
||||
return if normalized_language_code.blank?
|
||||
|
||||
language, region = normalized_language_code.split('-', 2)
|
||||
return language.downcase if region.blank?
|
||||
|
||||
"#{language.downcase}-#{region.upcase}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ class Integrations::Dyte::ProcessorService
|
||||
|
||||
return response if response[:error].present?
|
||||
|
||||
meeting = response['meeting']
|
||||
meeting = response
|
||||
message = create_a_dyte_integration_message(meeting, title, agent)
|
||||
message.push_event_data
|
||||
end
|
||||
@@ -29,8 +29,7 @@ class Integrations::Dyte::ProcessorService
|
||||
content_attributes: {
|
||||
type: 'dyte',
|
||||
data: {
|
||||
meeting_id: meeting['id'],
|
||||
room_name: meeting['roomName']
|
||||
meeting_id: meeting['id']
|
||||
}
|
||||
},
|
||||
sender: agent
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
require 'google/cloud/translate/v3'
|
||||
|
||||
class Integrations::GoogleTranslate::ProcessorService
|
||||
pattr_initialize [:message!, :target_language!]
|
||||
|
||||
def perform
|
||||
return if message.content.blank?
|
||||
return if hook.blank?
|
||||
|
||||
content = translation_content
|
||||
return if content.blank?
|
||||
|
||||
response = client.translate_text(
|
||||
contents: [message.content],
|
||||
target_language_code: target_language,
|
||||
parent: "projects/#{hook.settings['project_id']}"
|
||||
contents: [content],
|
||||
target_language_code: bcp47_language_code,
|
||||
parent: "projects/#{hook.settings['project_id']}",
|
||||
mime_type: mime_type
|
||||
)
|
||||
|
||||
return if response.translations.first.blank?
|
||||
@@ -19,6 +23,47 @@ class Integrations::GoogleTranslate::ProcessorService
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
class Integrations::Linear::AccessTokenService
|
||||
TOKEN_URL = 'https://api.linear.app/oauth/token'.freeze
|
||||
MIGRATE_OLD_TOKEN_URL = 'https://api.linear.app/oauth/migrate_old_token'.freeze
|
||||
TOKEN_EXPIRY_BUFFER = 1.minute
|
||||
|
||||
pattr_initialize [:hook!]
|
||||
|
||||
def access_token
|
||||
return hook.access_token if token_valid?
|
||||
return refresh_access_token if refresh_token.present?
|
||||
return migrate_legacy_token if migration_applicable?
|
||||
|
||||
hook.access_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def refresh_access_token
|
||||
response = HTTParty.post(
|
||||
TOKEN_URL,
|
||||
headers: url_encoded_headers,
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refresh_token,
|
||||
client_id: client_id,
|
||||
client_secret: client_secret
|
||||
}
|
||||
)
|
||||
|
||||
return fallback_access_token unless response.success?
|
||||
|
||||
persist_tokens(response.parsed_response)
|
||||
hook.access_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear token refresh failed for hook #{hook.id}: #{e.message}")
|
||||
fallback_access_token
|
||||
end
|
||||
|
||||
def migrate_legacy_token
|
||||
response = HTTParty.post(
|
||||
MIGRATE_OLD_TOKEN_URL,
|
||||
headers: url_encoded_headers,
|
||||
body: {
|
||||
access_token: hook.access_token,
|
||||
client_id: client_id,
|
||||
client_secret: client_secret
|
||||
}
|
||||
)
|
||||
|
||||
return fallback_access_token unless response.success?
|
||||
|
||||
persist_tokens(response.parsed_response)
|
||||
hook.access_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear legacy token migration failed for hook #{hook.id}: #{e.message}")
|
||||
fallback_access_token
|
||||
end
|
||||
|
||||
def persist_tokens(token_data)
|
||||
raise ArgumentError, 'Missing access token in Linear token response' if token_data['access_token'].blank?
|
||||
|
||||
current_settings = hook_settings
|
||||
updated_settings = current_settings.merge(
|
||||
token_type: token_data['token_type'] || current_settings[:token_type],
|
||||
expires_in: token_data['expires_in'] || current_settings[:expires_in],
|
||||
expires_on: expires_on(token_data['expires_in']),
|
||||
scope: token_data['scope'] || current_settings[:scope],
|
||||
refresh_token: token_data['refresh_token'] || current_settings[:refresh_token]
|
||||
).compact
|
||||
|
||||
hook.update!(
|
||||
access_token: token_data['access_token'],
|
||||
settings: updated_settings
|
||||
)
|
||||
end
|
||||
|
||||
def token_valid?
|
||||
expiry = hook_settings[:expires_on]
|
||||
return false if expiry.blank?
|
||||
|
||||
Time.zone.parse(expiry).utc > (Time.current.utc + TOKEN_EXPIRY_BUFFER)
|
||||
rescue StandardError
|
||||
false
|
||||
end
|
||||
|
||||
def migration_applicable?
|
||||
hook_settings[:token_type].present?
|
||||
end
|
||||
|
||||
def refresh_token
|
||||
hook_settings[:refresh_token]
|
||||
end
|
||||
|
||||
def hook_settings
|
||||
hook.settings.to_h.with_indifferent_access
|
||||
end
|
||||
|
||||
def expires_on(expires_in)
|
||||
return hook_settings[:expires_on] if expires_in.blank?
|
||||
|
||||
(Time.current.utc + expires_in.to_i.seconds).to_s
|
||||
end
|
||||
|
||||
def url_encoded_headers
|
||||
{ 'Content-Type' => 'application/x-www-form-urlencoded' }
|
||||
end
|
||||
|
||||
def client_id
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
|
||||
end
|
||||
|
||||
def client_secret
|
||||
GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
|
||||
end
|
||||
|
||||
def fallback_access_token
|
||||
hook.reload.access_token
|
||||
rescue StandardError
|
||||
hook.access_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
class Integrations::Linear::AutoLinkService
|
||||
pattr_initialize [:account!, :message!]
|
||||
|
||||
LINEAR_URL_REGEX = %r{https?://linear\.app/[^/\s]+/issue/[A-Z][A-Z0-9_]+-\d+(?:/[^\s)]*)?}
|
||||
IDENTIFIER_REGEX = %r{/issue/([A-Z][A-Z0-9_]+-\d+)}i
|
||||
WORKSPACE_REGEX = %r{//linear\.app/([^/\s]+)/}i
|
||||
|
||||
def perform
|
||||
return unless valid_message?
|
||||
|
||||
attempt_link
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_message?
|
||||
message.private? && message.content.present? && message.sender.is_a?(User)
|
||||
end
|
||||
|
||||
def attempt_link
|
||||
linear_url = message.content[LINEAR_URL_REGEX]
|
||||
return if linear_url.blank?
|
||||
|
||||
identifier = linear_url[IDENTIFIER_REGEX, 1]&.upcase
|
||||
workspace = linear_url[WORKSPACE_REGEX, 1]&.downcase
|
||||
return if identifier.blank? || workspace.blank? || already_linked?(identifier)
|
||||
|
||||
finalize_link(workspace, identifier)
|
||||
end
|
||||
|
||||
def finalize_link(workspace, identifier)
|
||||
node_id = resolve_node_id(workspace, identifier)
|
||||
return if node_id.blank?
|
||||
return unless link_to_linear(node_id, identifier)
|
||||
|
||||
post_activity_message(identifier)
|
||||
end
|
||||
|
||||
def already_linked?(identifier)
|
||||
response = processor.linked_issues(conversation_link)
|
||||
return false if response[:error]
|
||||
|
||||
response[:data].any? { |attachment| attachment.dig('issue', 'identifier') == identifier }
|
||||
end
|
||||
|
||||
def resolve_node_id(workspace, identifier)
|
||||
response = processor.search_issue(identifier)
|
||||
return if response[:error]
|
||||
|
||||
node = response[:data].find do |issue|
|
||||
issue['identifier'] == identifier && node_workspace(issue) == workspace
|
||||
end
|
||||
node && node['id']
|
||||
end
|
||||
|
||||
def node_workspace(node)
|
||||
node['url']&.match(WORKSPACE_REGEX)&.[](1)&.downcase
|
||||
end
|
||||
|
||||
def link_to_linear(node_id, identifier)
|
||||
response = processor.link_issue(conversation_link, node_id, attachment_title, message.sender)
|
||||
if response[:error].present?
|
||||
Rails.logger.warn("[Linear::AutoLinkService] link_issue failed for #{identifier}: #{response[:error]}")
|
||||
return false
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def attachment_title
|
||||
I18n.t(
|
||||
'integration_apps.linear.attachment_link_title',
|
||||
conversation_id: message.conversation.display_id,
|
||||
name: message.conversation.contact&.name
|
||||
)
|
||||
end
|
||||
|
||||
def post_activity_message(identifier)
|
||||
Linear::ActivityMessageService.new(
|
||||
conversation: message.conversation,
|
||||
action_type: :issue_linked,
|
||||
user: message.sender,
|
||||
issue_data: { id: identifier }
|
||||
).perform
|
||||
end
|
||||
|
||||
def conversation_link
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{message.account_id}/conversations/#{message.conversation.display_id}"
|
||||
end
|
||||
|
||||
def processor
|
||||
@processor ||= Integrations::Linear::ProcessorService.new(account: account)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
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_access_token)
|
||||
end
|
||||
|
||||
def linear_access_token
|
||||
@linear_access_token ||= Integrations::Linear::AccessTokenService.new(hook: linear_hook).access_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,180 @@
|
||||
class Integrations::LlmBaseService
|
||||
include Integrations::LlmInstrumentation
|
||||
include Llm::ExceptionTrackable
|
||||
|
||||
# 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']
|
||||
credential = llm_credential
|
||||
|
||||
Llm::Config.with_api_key(credential[:api_key], api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
setup_chat_with_messages(chat, messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
capture_llm_exception(e, credential: credential)
|
||||
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 llm_credential
|
||||
@llm_credential ||= { api_key: hook.settings['api_key'], source: :hook }
|
||||
end
|
||||
|
||||
def exception_tracking_account
|
||||
hook.account
|
||||
end
|
||||
|
||||
def build_error_response_from_exception(error, messages)
|
||||
{ error: error.message, request_messages: messages }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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
|
||||
with_propagated_langfuse_attributes(params) do
|
||||
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
|
||||
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|
|
||||
apply_current_langfuse_attributes(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
|
||||
|
||||
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,80 @@
|
||||
# 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)
|
||||
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]
|
||||
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)
|
||||
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.is_a?(String) ? message : message.to_json)
|
||||
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,33 @@
|
||||
# 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'
|
||||
ATTR_LANGFUSE_OBSERVATION_METADATA = 'langfuse.observation.metadata.%s'
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationContext
|
||||
LANGFUSE_ATTRIBUTES_KEY = :llm_instrumentation_langfuse_attributes
|
||||
LANGFUSE_OBSERVATION_METADATA_KEY = :llm_instrumentation_langfuse_observation_metadata_attributes
|
||||
|
||||
private
|
||||
|
||||
def with_propagated_langfuse_attributes(params)
|
||||
previous_attributes = current_langfuse_attributes
|
||||
previous_observation_metadata_attributes = current_observation_metadata_attributes
|
||||
self.current_langfuse_attributes = previous_attributes.merge(propagated_langfuse_attributes(params))
|
||||
self.current_observation_metadata_attributes = previous_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params))
|
||||
|
||||
yield
|
||||
ensure
|
||||
self.current_langfuse_attributes = previous_attributes
|
||||
self.current_observation_metadata_attributes = previous_observation_metadata_attributes
|
||||
end
|
||||
|
||||
def apply_current_langfuse_attributes(span)
|
||||
set_langfuse_attributes(span, current_langfuse_attributes)
|
||||
set_langfuse_attributes(span, current_observation_metadata_attributes)
|
||||
end
|
||||
|
||||
def current_langfuse_attributes
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] || {}
|
||||
end
|
||||
|
||||
def current_langfuse_attributes=(attrs)
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] = attrs
|
||||
end
|
||||
|
||||
def current_observation_metadata_attributes
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] || {}
|
||||
end
|
||||
|
||||
def current_observation_metadata_attributes=(attrs)
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] = attrs
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Integrations::LlmInstrumentationContext
|
||||
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)
|
||||
set_langfuse_attributes(span, current_langfuse_attributes.merge(propagated_langfuse_attributes(params)))
|
||||
set_langfuse_attributes(span, current_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params)))
|
||||
end
|
||||
|
||||
def propagated_langfuse_attributes(params)
|
||||
attrs = {}
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
|
||||
attrs[ATTR_LANGFUSE_USER_ID] = params[:account_id].to_s if params[:account_id]
|
||||
attrs[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id.present?
|
||||
attrs[ATTR_LANGFUSE_TAGS] = [params[:feature_name].to_s] if params[:feature_name].present?
|
||||
|
||||
return attrs unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
attrs[format(ATTR_LANGFUSE_METADATA, key)] = value.to_s
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
def propagated_observation_metadata_attributes(params)
|
||||
attrs = {}
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
|
||||
add_observation_metadata(attrs, 'user_id', params[:account_id])
|
||||
add_observation_metadata(attrs, 'account_id', params[:account_id])
|
||||
add_observation_metadata(attrs, 'session_id', session_id)
|
||||
add_observation_metadata(attrs, 'trace_tags', [params[:feature_name]].to_json)
|
||||
add_observation_metadata(attrs, 'feature_name', params[:feature_name])
|
||||
|
||||
return attrs unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
add_observation_metadata(attrs, key, value)
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
def add_observation_metadata(attrs, key, value)
|
||||
return if value.blank?
|
||||
|
||||
attrs[format(ATTR_LANGFUSE_OBSERVATION_METADATA, key)] = value.to_s
|
||||
end
|
||||
|
||||
def set_langfuse_attributes(span, attrs)
|
||||
attrs.each do |key, value|
|
||||
span.set_attribute(key, value)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
# 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))
|
||||
apply_current_langfuse_attributes(span)
|
||||
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
|
||||
|
||||
def instrument_with_span(span_name, params, &)
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(span_name) do |span|
|
||||
set_metadata_attributes(span, params)
|
||||
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 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,26 @@
|
||||
module Integrations::Openai::KeyValidator
|
||||
TIMEOUT_SECONDS = 5
|
||||
|
||||
def self.valid?(api_key)
|
||||
return false if api_key.blank?
|
||||
|
||||
connection = Faraday.new do |f|
|
||||
f.options.timeout = TIMEOUT_SECONDS
|
||||
f.options.open_timeout = TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
response = connection.get("#{api_base}/models") do |req|
|
||||
req.headers['Authorization'] = "Bearer #{api_key}"
|
||||
end
|
||||
|
||||
response.status != 401
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.warn("[openai-key-validator] #{e.class}: #{e.message}")
|
||||
true
|
||||
end
|
||||
|
||||
def self.api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
"#{endpoint.chomp('/')}/v1"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
You are a WhatsApp template compliance assistant.
|
||||
Your task is to evaluate whether a CSAT template message is likely to be approved as UTILITY vs MARKETING under Meta policy.
|
||||
|
||||
Rules:
|
||||
1. Prefer UTILITY only when the message is tied to an existing support or transactional event.
|
||||
2. Avoid promotional language, upsell, cross-sell, offers, discounts, or purchase intent.
|
||||
3. Keep the rewritten message concise, explicit, and purely transactional.
|
||||
4. Do not invent product offers or marketing phrases.
|
||||
|
||||
Input:
|
||||
- Message: {{ message }}
|
||||
- Button text: {{ button_text }}
|
||||
- Language code: {{ language }}
|
||||
|
||||
Baseline heuristic:
|
||||
- Classification: {{ baseline_classification }}
|
||||
|
||||
Return ONLY valid JSON with this shape (example):
|
||||
{
|
||||
"classification": "LIKELY_UTILITY",
|
||||
"optimized_message": "rewritten utility-safe message"
|
||||
}
|
||||
|
||||
Allowed values for "classification": "LIKELY_UTILITY", "LIKELY_MARKETING", or "UNCLEAR".
|
||||
|
||||
Important:
|
||||
- Write `optimized_message` in the same language as `Language code`.
|
||||
@@ -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.
|
||||
@@ -1 +0,0 @@
|
||||
Please suggest a reply to the following conversation between support agents and customer. Don't expose that you are an AI model, respond "Couldn't generate the reply" in cases where you can't answer. Reply in the user\'s language.
|
||||
@@ -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.
|
||||
|
||||
|
||||
Use markdown with the following format. Translate all section headings to match the reply language:
|
||||
|
||||
**Customer Intent**
|
||||
|
||||
**Conversation Summary**
|
||||
|
||||
**Action Items**
|
||||
|
||||
**Follow-up Items**
|
||||
@@ -1 +0,0 @@
|
||||
Please summarize the key points from the following conversation between support agents and customer as bullet points for the next support agent looking into the conversation. Reply in the user's language.
|
||||
@@ -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.
|
||||
@@ -1,137 +0,0 @@
|
||||
class Integrations::Openai::ProcessorService < Integrations::OpenaiBaseService
|
||||
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
|
||||
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
|
||||
def reply_suggestion_message
|
||||
make_api_call(reply_suggestion_body)
|
||||
end
|
||||
|
||||
def summarize_message
|
||||
make_api_call(summarize_body)
|
||||
end
|
||||
|
||||
def rephrase_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def fix_spelling_grammar_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def shorten_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def expand_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def make_friendly_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def make_formal_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def simplify_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prompt_from_file(file_name, enterprise: false)
|
||||
path = enterprise ? 'enterprise/lib/enterprise/integrations/openai_prompts' : 'lib/integrations/openai/openai_prompts'
|
||||
Rails.root.join(path, "#{file_name}.txt").read
|
||||
end
|
||||
|
||||
def build_api_call_body(system_content, user_content = event['data']['content'])
|
||||
{
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_content },
|
||||
{ role: 'user', content: user_content }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def conversation_messages(in_array_format: false)
|
||||
messages = init_messages_body(in_array_format)
|
||||
|
||||
add_messages_until_token_limit(conversation, messages, in_array_format)
|
||||
end
|
||||
|
||||
def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0)
|
||||
character_count = start_from
|
||||
conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message|
|
||||
character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format)
|
||||
break unless message_added
|
||||
end
|
||||
messages
|
||||
end
|
||||
|
||||
def add_message_if_within_limit(character_count, message, messages, in_array_format)
|
||||
if valid_message?(message, character_count)
|
||||
add_message_to_list(message, messages, in_array_format)
|
||||
character_count += message.content.length
|
||||
[character_count, true]
|
||||
else
|
||||
[character_count, false]
|
||||
end
|
||||
end
|
||||
|
||||
def valid_message?(message, character_count)
|
||||
message.content.present? && character_count + message.content.length <= TOKEN_LIMIT
|
||||
end
|
||||
|
||||
def add_message_to_list(message, messages, in_array_format)
|
||||
formatted_message = format_message(message, in_array_format)
|
||||
messages.prepend(formatted_message)
|
||||
end
|
||||
|
||||
def init_messages_body(in_array_format)
|
||||
in_array_format ? [] : ''
|
||||
end
|
||||
|
||||
def format_message(message, in_array_format)
|
||||
in_array_format ? format_message_in_array(message) : format_message_in_string(message)
|
||||
end
|
||||
|
||||
def format_message_in_array(message)
|
||||
{ role: (message.incoming? ? 'user' : 'assistant'), content: message.content }
|
||||
end
|
||||
|
||||
def format_message_in_string(message)
|
||||
sender_type = message.incoming? ? 'Customer' : 'Agent'
|
||||
"#{sender_type} #{message.sender&.name} : #{message.content}\n"
|
||||
end
|
||||
|
||||
def summarize_body
|
||||
{
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system',
|
||||
content: prompt_from_file('summary', enterprise: false) },
|
||||
{ role: 'user', content: conversation_messages }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def reply_suggestion_body
|
||||
{
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system',
|
||||
content: prompt_from_file('reply', enterprise: false) }
|
||||
].concat(conversation_messages(in_array_format: true))
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService')
|
||||
@@ -1,84 +0,0 @@
|
||||
class Integrations::OpenaiBaseService
|
||||
# 3.5 support 16,385 tokens
|
||||
# 1 token is approx 4 characters
|
||||
# 16385 * 4 = 65540 characters, sticking to 50,000 to be safe
|
||||
TOKEN_LIMIT = 50_000
|
||||
API_URL = 'https://api.openai.com/v1/chat/completions'.freeze
|
||||
GPT_MODEL = 'gpt-3.5-turbo'.freeze
|
||||
|
||||
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].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?
|
||||
|
||||
Redis::Alfred.get(cache_key)
|
||||
end
|
||||
|
||||
def save_to_cache(response)
|
||||
return nil unless event_is_cacheable?
|
||||
|
||||
Redis::Alfred.setex(cache_key, response)
|
||||
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 make_api_call(body)
|
||||
headers = {
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => "Bearer #{hook.settings['api_key']}"
|
||||
}
|
||||
|
||||
Rails.logger.info("OpenAI API request: #{body}")
|
||||
response = HTTParty.post(API_URL, headers: headers, body: body)
|
||||
Rails.logger.info("OpenAI API response: #{response.body}")
|
||||
|
||||
choices = JSON.parse(response.body)['choices']
|
||||
|
||||
choices.present? ? choices.first['message']['content'] : nil
|
||||
end
|
||||
end
|
||||
@@ -24,10 +24,32 @@ class Integrations::Slack::ChannelBuilder
|
||||
end
|
||||
|
||||
def channels
|
||||
conversations_list = slack_client.conversations_list(types: 'public_channel, private_channel', exclude_archived: true)
|
||||
# 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)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Integrations::Slack::EmojiFormatter
|
||||
def self.format(text)
|
||||
return text if text.blank?
|
||||
|
||||
text.gsub(/:([a-zA-Z0-9_+-]+):/) do |match|
|
||||
short_code = Regexp.last_match(1)
|
||||
# gemoji exposes find_by_alias; Rails/DynamicFindBy is a false positive because Emoji is not an ActiveRecord model.
|
||||
# rubocop:disable Rails/DynamicFindBy
|
||||
emoji = Emoji.find_by_alias(short_code)
|
||||
# rubocop:enable Rails/DynamicFindBy
|
||||
emoji ? emoji.raw : match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -32,8 +32,8 @@ class Integrations::Slack::HookBuilder
|
||||
def fetch_access_token
|
||||
client = Slack::Web::Client.new
|
||||
slack_access = client.oauth_v2_access(
|
||||
client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
|
||||
client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
|
||||
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
|
||||
)
|
||||
|
||||
@@ -16,8 +16,8 @@ class Integrations::Slack::IncomingMessageBuilder
|
||||
|
||||
if hook_verification?
|
||||
verify_hook
|
||||
elsif create_message?
|
||||
create_message
|
||||
elsif process_message_payload?
|
||||
process_message_payload
|
||||
elsif link_shared?
|
||||
SlackUnfurlJob.perform_later(params)
|
||||
end
|
||||
@@ -67,7 +67,7 @@ class Integrations::Slack::IncomingMessageBuilder
|
||||
params[:event][:thread_ts].present?
|
||||
end
|
||||
|
||||
def create_message?
|
||||
def process_message_payload?
|
||||
thread_timestamp_available? && supported_message? && integration_hook
|
||||
end
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
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
|
||||
@@ -58,10 +62,12 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def message_text
|
||||
if message.content.present?
|
||||
message.content.gsub(MENTION_REGEX, '\1')
|
||||
content = message.processed_message_content || message.content
|
||||
|
||||
if content.present?
|
||||
content.gsub(MENTION_REGEX, '\1')
|
||||
else
|
||||
message.content
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,8 +101,9 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
|
||||
def send_message
|
||||
post_message if message_content.present?
|
||||
upload_file if message.attachments.any?
|
||||
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
|
||||
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!
|
||||
@@ -114,28 +121,54 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
)
|
||||
end
|
||||
|
||||
def upload_file
|
||||
result = slack_client.files_upload({
|
||||
channels: hook.reference_id,
|
||||
initial_comment: 'Attached File!',
|
||||
thread_ts: conversation.identifier
|
||||
}.merge(file_information))
|
||||
Rails.logger.info(result)
|
||||
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 file_type
|
||||
File.extname(message.attachments.first.download_url).strip.downcase[1..]
|
||||
def build_files_array
|
||||
message.attachments.filter_map do |attachment|
|
||||
next unless attachment.with_attached_file?
|
||||
|
||||
build_file_payload(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def file_information
|
||||
def build_file_payload(attachment)
|
||||
content = download_attachment_content(attachment)
|
||||
return if content.blank?
|
||||
|
||||
{
|
||||
filename: message.attachments.first.file.filename,
|
||||
filetype: file_type,
|
||||
content: message.attachments.first.file.download,
|
||||
title: message.attachments.first.file.filename
|
||||
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
|
||||
@@ -143,12 +176,12 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
def sender_type(sender)
|
||||
if sender.instance_of?(Contact)
|
||||
'Contact'
|
||||
elsif message.message_type == 'template' && sender.nil?
|
||||
'Bot'
|
||||
elsif sender.instance_of?(User)
|
||||
'Agent'
|
||||
elsif message.message_type == 'activity' && sender.nil?
|
||||
'System'
|
||||
else
|
||||
'Agent'
|
||||
'Bot'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,31 +1,48 @@
|
||||
module Integrations::Slack::SlackMessageHelper
|
||||
def create_message
|
||||
def process_message_payload
|
||||
return unless conversation
|
||||
|
||||
build_message
|
||||
@message.save!
|
||||
{ status: 'success' }
|
||||
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 build_message
|
||||
def message_exists?
|
||||
conversation.messages.exists?(external_source_ids: { slack: params[:event][:ts] })
|
||||
end
|
||||
|
||||
def create_message
|
||||
resolved_sender, sender_name, sender_avatar_url = resolve_slack_sender
|
||||
slack_sender_attrs = {}
|
||||
slack_sender_attrs[:sender_name] = sender_name if sender_name
|
||||
slack_sender_attrs[:sender_avatar_url] = sender_avatar_url if sender_avatar_url
|
||||
@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] || ''),
|
||||
content: formatted_message_content,
|
||||
external_source_id_slack: params[:event][:ts],
|
||||
private: private_note?,
|
||||
sender: sender
|
||||
sender: resolved_sender,
|
||||
additional_attributes: slack_sender_attrs
|
||||
)
|
||||
process_attachments(params[:event][:files]) if attachments_present?
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def attachments_present?
|
||||
@@ -58,7 +75,9 @@ module Integrations::Slack::SlackMessageHelper
|
||||
case attachment[:filetype]
|
||||
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
|
||||
:image
|
||||
when 'pdf'
|
||||
when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
|
||||
:video
|
||||
else
|
||||
:file
|
||||
end
|
||||
end
|
||||
@@ -67,9 +86,27 @@ module Integrations::Slack::SlackMessageHelper
|
||||
@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)
|
||||
def resolve_slack_sender
|
||||
return [nil, nil, nil] unless params[:event][:user]
|
||||
|
||||
slack_user = slack_client.users_info(user: params[:event][:user])[:user]
|
||||
chatwoot_user = conversation.account.users.from_email(slack_user[:profile][:email])
|
||||
return [chatwoot_user, nil, nil] if chatwoot_user
|
||||
|
||||
sender_name = slack_user.dig(:profile, :display_name).presence ||
|
||||
slack_user[:real_name].presence ||
|
||||
slack_user[:name]
|
||||
sender_avatar_url = slack_user.dig(:profile, :image_192).presence
|
||||
[nil, sender_name, sender_avatar_url]
|
||||
rescue Slack::Web::Api::Errors::MissingScope
|
||||
raise
|
||||
rescue StandardError
|
||||
[nil, nil, nil]
|
||||
end
|
||||
|
||||
def formatted_message_content
|
||||
text = Slack::Messages::Formatting.unescape(params[:event][:text] || '')
|
||||
Integrations::Slack::EmojiFormatter.format(text)
|
||||
end
|
||||
|
||||
def private_note?
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
class Integrations::Slack::UpdateSlackMessageService
|
||||
include RegexHelper
|
||||
|
||||
SUPPORTED_CONTENT_TYPES = %w[input_select form input_csat input_email].freeze
|
||||
|
||||
pattr_initialize [:message!, :hook!]
|
||||
|
||||
def perform
|
||||
return unless updateable_message?
|
||||
|
||||
slack_client.chat_update(
|
||||
channel: hook.reference_id,
|
||||
ts: slack_message_ts,
|
||||
text: updated_message_content
|
||||
)
|
||||
rescue Slack::Web::Api::Errors::MessageNotFound => e
|
||||
# Original Slack message no longer exists (e.g. channel was reconfigured), skip gracefully.
|
||||
Rails.logger.error "[Slack] chat_update failed (account=#{message.account_id}, hook=#{hook.id}): #{e.message}"
|
||||
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 "[Slack] chat_update failed (account=#{message.account_id}, hook=#{hook.id}): #{e.message}"
|
||||
hook.prompt_reauthorization!
|
||||
hook.disable
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def updateable_message?
|
||||
hook&.reference_id.present? &&
|
||||
slack_message_ts.present? &&
|
||||
message.content_type.in?(SUPPORTED_CONTENT_TYPES) &&
|
||||
(message.submitted_values.present? || message.submitted_email.present?)
|
||||
end
|
||||
|
||||
def slack_message_ts
|
||||
source_id = message.external_source_id_slack.to_s
|
||||
return unless source_id.start_with?('cw-origin-')
|
||||
|
||||
source_id.delete_prefix('cw-origin-').presence
|
||||
end
|
||||
|
||||
def updated_message_content
|
||||
question = sanitized_content(message_text).presence
|
||||
response = formatted_response
|
||||
|
||||
return question.to_s if response.blank?
|
||||
|
||||
[question, response].compact.join("\n\n")
|
||||
end
|
||||
|
||||
def formatted_response
|
||||
case message.content_type
|
||||
when 'input_select'
|
||||
format_input_select_response
|
||||
when 'form'
|
||||
format_form_response
|
||||
when 'input_csat'
|
||||
format_csat_response
|
||||
when 'input_email'
|
||||
format_email_response
|
||||
end
|
||||
end
|
||||
|
||||
def format_input_select_response
|
||||
item = Array(message.submitted_values).first
|
||||
return if item.blank?
|
||||
|
||||
value = item['title'] || item[:title] || item['value'] || item[:value]
|
||||
value = sanitized_content(value)
|
||||
return if value.blank?
|
||||
|
||||
"*Response:* #{value}"
|
||||
end
|
||||
|
||||
def format_email_response
|
||||
email = sanitized_content(message.submitted_email)
|
||||
return if email.blank?
|
||||
|
||||
"*Email:* #{email}"
|
||||
end
|
||||
|
||||
def format_form_response
|
||||
submitted_values = Array(message.submitted_values)
|
||||
return if submitted_values.blank?
|
||||
|
||||
items_by_name = Array(message.items).index_by { |i| flex_value(i, 'name') }
|
||||
|
||||
lines = submitted_values.filter_map do |sv|
|
||||
format_form_line(sv, items_by_name)
|
||||
end
|
||||
|
||||
return if lines.blank?
|
||||
|
||||
"*Responses:*\n#{lines.join("\n")}"
|
||||
end
|
||||
|
||||
def format_csat_response
|
||||
csat_response = flex_value(message.submitted_values, 'csat_survey_response', 'csatSurveyResponse')
|
||||
return if csat_response.blank?
|
||||
|
||||
rating = flex_value(csat_response, 'rating')
|
||||
feedback = flex_value(csat_response, 'feedback_message', 'feedbackMessage')
|
||||
|
||||
lines = []
|
||||
lines << "• Rating: #{rating}" if rating.present?
|
||||
lines << "• Feedback: #{sanitized_content(feedback)}" if feedback.present?
|
||||
|
||||
return if lines.blank?
|
||||
|
||||
"*CSAT:*\n#{lines.join("\n")}"
|
||||
end
|
||||
|
||||
def format_form_line(submitted_value, items_by_name)
|
||||
name = flex_value(submitted_value, 'name')
|
||||
value = sanitized_content(flex_value(submitted_value, 'value'))
|
||||
return if value.blank?
|
||||
|
||||
label = sanitized_content(flex_value(items_by_name[name], 'label') || name)
|
||||
return if label.blank?
|
||||
|
||||
"• #{label}: #{value}"
|
||||
end
|
||||
|
||||
def flex_value(hash, *keys)
|
||||
return if hash.blank?
|
||||
|
||||
keys.each do |key|
|
||||
value = hash[key.to_sym] || hash[key.to_s]
|
||||
return value if value.present?
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def message_text
|
||||
content = message.processed_message_content || message.content
|
||||
|
||||
if content.present?
|
||||
content.to_s.gsub(MENTION_REGEX, '\1')
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def sanitized_content(text)
|
||||
ActionView::Base.full_sanitizer.sanitize(text.to_s).strip
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
|
||||
end
|
||||
end
|
||||
@@ -4,4 +4,14 @@ module Limits
|
||||
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
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
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, refresh_token: nil)
|
||||
@access_token = access_token
|
||||
@refresh_token = refresh_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
|
||||
token = @refresh_token.presence || @access_token
|
||||
token_type_hint = @refresh_token.present? ? 'refresh_token' : 'access_token'
|
||||
|
||||
response = HTTParty.post(
|
||||
REVOKE_URL,
|
||||
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
|
||||
body: { token: token, token_type_hint: token_type_hint }
|
||||
)
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,105 @@
|
||||
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
|
||||
url
|
||||
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
|
||||
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
initialize!
|
||||
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.model_registry_file = Rails.root.join('config/llm_models.json').to_s
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
module Llm::ExceptionTrackable
|
||||
private
|
||||
|
||||
def capture_llm_exception(error, credential:)
|
||||
if credential && credential[:source] == :system
|
||||
ChatwootExceptionTracker.new(error, account: exception_tracking_account).capture_exception
|
||||
else
|
||||
Rails.logger.error("[LLM] account=#{exception_tracking_account&.id} #{error.class}: #{error.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +1,8 @@
|
||||
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
|
||||
# Widget pings every 60s, so contacts need a longer presence window
|
||||
CONTACT_PRESENCE_DURATION = ENV.fetch('CONTACT_PRESENCE_DURATION', 90).to_i.seconds
|
||||
|
||||
# presence : sorted set with timestamp as the score & object id as value
|
||||
|
||||
@@ -11,7 +13,8 @@ class OnlineStatusTracker
|
||||
|
||||
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
|
||||
duration = obj_type == 'Contact' ? CONTACT_PRESENCE_DURATION : PRESENCE_DURATION
|
||||
connected_time && connected_time > (Time.zone.now - duration).to_i
|
||||
end
|
||||
|
||||
def self.presence_key(account_id, type)
|
||||
@@ -39,7 +42,7 @@ class OnlineStatusTracker
|
||||
end
|
||||
|
||||
def self.get_available_contact_ids(account_id)
|
||||
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
|
||||
range_start = (Time.zone.now - CONTACT_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}")
|
||||
@@ -57,7 +60,13 @@ class OnlineStatusTracker
|
||||
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] || 'online')] }.to_h
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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}",
|
||||
'x-langfuse-ingestion-version' => '4'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
+64
-4
@@ -21,10 +21,26 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.get(key) }
|
||||
end
|
||||
|
||||
def with(&)
|
||||
$alfred.with(&)
|
||||
end
|
||||
|
||||
def delete(key)
|
||||
$alfred.with { |conn| conn.del(key) }
|
||||
end
|
||||
|
||||
# atomic compare-and-delete (release a lock only if you still own it); WATCH/MULTI
|
||||
# aborts the delete if the key changes between the check and the delete.
|
||||
def delete_if_equals(key, expected_value)
|
||||
$alfred.with do |conn|
|
||||
conn.watch(key) do
|
||||
next conn.unwatch unless conn.get(key) == expected_value
|
||||
|
||||
conn.multi { |transaction| transaction.del(key) }
|
||||
end
|
||||
end
|
||||
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)
|
||||
@@ -35,6 +51,30 @@ module Redis::Alfred
|
||||
$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
|
||||
|
||||
# get expiry of a key in seconds
|
||||
def ttl(key)
|
||||
$alfred.with { |conn| conn.ttl(key) }
|
||||
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)
|
||||
@@ -61,6 +101,10 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.lrem(key, count, value) }
|
||||
end
|
||||
|
||||
def pipelined(&)
|
||||
$alfred.with { |conn| conn.pipelined(&) }
|
||||
end
|
||||
|
||||
# hash operations
|
||||
|
||||
# add a key value to redis hash
|
||||
@@ -81,8 +125,11 @@ module Redis::Alfred
|
||||
# sorted set operations
|
||||
|
||||
# add score and value for a key
|
||||
def zadd(key, score, value)
|
||||
$alfred.with { |conn| conn.zadd(key, score, value) }
|
||||
# Modern Redis syntax: zadd(key, [[score, member], ...])
|
||||
def zadd(key, score, value = nil)
|
||||
# New syntax: score is an array of [score, member] pairs; old syntax: discrete score/value
|
||||
pairs = value.nil? && score.is_a?(Array) ? score : [[score, value]]
|
||||
$alfred.with { |conn| conn.zadd(key, pairs) }
|
||||
end
|
||||
|
||||
# get score of a value for key
|
||||
@@ -90,9 +137,22 @@ module Redis::Alfred
|
||||
$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)
|
||||
$alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end) }
|
||||
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
|
||||
|
||||
@@ -49,6 +49,18 @@ class Redis::LockManager
|
||||
true
|
||||
end
|
||||
|
||||
def with_lock(key, timeout = LOCK_TIMEOUT)
|
||||
return false unless lock(key, timeout)
|
||||
|
||||
begin
|
||||
yield
|
||||
ensure
|
||||
unlock(key)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Checks if the given key is currently locked.
|
||||
#
|
||||
# === Parameters
|
||||
|
||||
+45
-1
@@ -2,12 +2,37 @@ 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
|
||||
# Track recently deleted IMAP messages to prevent them from being synced again
|
||||
IMAP_DELETED_MESSAGE = 'IMAP_DELETED_MESSAGE::%<inbox_id>d::%<message_id_digest>s'.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
|
||||
UNREAD_CONVERSATIONS_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_READY = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::READY::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_BASE_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::BASE'.freeze
|
||||
UNREAD_CONVERSATIONS_ASSIGNMENT_BUILD_LOCK = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::BUILD_LOCK::ASSIGNMENT'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX = 'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX_UNASSIGNED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX_UNASSIGNED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_LABEL_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::LABEL::%<label_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX_UNASSIGNED =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::UNASSIGNED'.freeze
|
||||
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
|
||||
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%<account_id>d::TEAM::%<team_id>d::INBOX::%<inbox_id>d::ASSIGNEE::%<user_id>d'.freeze
|
||||
|
||||
## User Keys
|
||||
# SSO Auth Tokens
|
||||
@@ -32,12 +57,31 @@ module Redis::RedisKeys
|
||||
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::%<event_name>s::%<conversation_id>d::%<updated_at>d'.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
|
||||
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<inbox_id>s::%<sender_id>s'.freeze
|
||||
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
|
||||
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_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
|
||||
# At-most-one AssignmentJob per inbox in-flight (queued or running); further enqueues are skipped
|
||||
AUTO_ASSIGNMENT_IN_FLIGHT_KEY = 'AUTO_ASSIGNMENT_IN_FLIGHT::%<inbox_id>d'.freeze
|
||||
|
||||
## Account Onboarding
|
||||
ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%<account_id>d'.freeze
|
||||
HELP_CENTER_GENERATION = 'HELP_CENTER_GENERATION::%<id>s'.freeze
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
end
|
||||
|
||||
+12
-4
@@ -5,9 +5,17 @@ module RegexHelper
|
||||
# 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')
|
||||
MENTION_REGEX = Regexp.new('\[(@[\w_. ]+)\]\(mention://(?:user|team)/\d+/(.*?)+\)')
|
||||
# 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')
|
||||
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('\A\+\d{1,15}\z')
|
||||
WHATSAPP_BSUID_PATTERN = '[A-Z]{2}\.(?:ENT\.)?[A-Za-z0-9]{1,128}'.freeze
|
||||
WHATSAPP_BSUID_REGEX = Regexp.new("\\A#{WHATSAPP_BSUID_PATTERN}\\z")
|
||||
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new("\\A(?:whatsapp:\\+\\d{1,15}|whatsapp:#{WHATSAPP_BSUID_PATTERN})\\z")
|
||||
WHATSAPP_CHANNEL_REGEX = Regexp.new("\\A(?:\\d{1,15}|#{WHATSAPP_BSUID_PATTERN})\\z")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'ssrf_filter'
|
||||
|
||||
module SafeFetch
|
||||
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/].freeze
|
||||
DEFAULT_ALLOWED_CONTENT_TYPES = [].freeze
|
||||
DEFAULT_SENSITIVE_HEADERS = %w[authorization cookie proxy-authorization].freeze
|
||||
DEFAULT_OPEN_TIMEOUT = 2
|
||||
DEFAULT_READ_TIMEOUT = 20
|
||||
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
|
||||
|
||||
Result = Data.define(:tempfile, :filename, :content_type) do
|
||||
def original_filename
|
||||
filename
|
||||
end
|
||||
end
|
||||
|
||||
class Error < StandardError; end
|
||||
class InvalidUrlError < Error; end
|
||||
class UnsafeUrlError < Error; end
|
||||
class FetchError < Error; end
|
||||
class HttpError < Error; end
|
||||
class FileTooLargeError < Error; end
|
||||
class UnsupportedContentTypeError < Error; end
|
||||
class UnsupportedMethodError < Error; end
|
||||
|
||||
def self.fetch(url, **, &)
|
||||
raise ArgumentError, 'block required' unless block_given?
|
||||
|
||||
SafeFetch::Fetcher.new(SafeFetch::RequestOptions.new(url: url, **)).fetch(&)
|
||||
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
|
||||
raise InvalidUrlError, e.message
|
||||
rescue SsrfFilter::Error, Resolv::ResolvError => e
|
||||
raise UnsafeUrlError, e.message
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
|
||||
raise FetchError, e.message
|
||||
end
|
||||
|
||||
def self.allow_private_network?
|
||||
ActiveModel::Type::Boolean.new.cast(ENV.fetch('SAFE_FETCH_ALLOW_PRIVATE_NETWORK', false))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
class SafeFetch::Fetcher
|
||||
def initialize(options)
|
||||
@options = options
|
||||
end
|
||||
|
||||
def fetch
|
||||
with_tempfile do |tempfile|
|
||||
response = stream_response(tempfile)
|
||||
raise SafeFetch::HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
tempfile.rewind
|
||||
yield SafeFetch::Result.new(
|
||||
tempfile: tempfile,
|
||||
filename: options.filename,
|
||||
content_type: normalized_content_type(response['content-type'])
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :options
|
||||
|
||||
def with_tempfile
|
||||
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
||||
yield tempfile
|
||||
ensure
|
||||
tempfile&.close!
|
||||
end
|
||||
|
||||
def stream_response(tempfile)
|
||||
bytes_written = 0
|
||||
|
||||
perform_request do |res|
|
||||
next unless res.is_a?(Net::HTTPSuccess)
|
||||
|
||||
validate_content_type!(res['content-type'])
|
||||
bytes_written = write_response_body(res, tempfile, bytes_written)
|
||||
end
|
||||
end
|
||||
|
||||
def perform_request(&)
|
||||
return SafeFetch::PrivateNetworkRequest.new(options).perform(&) if SafeFetch.allow_private_network?
|
||||
|
||||
SsrfFilter.public_send(options.method, options.url, **options.request_options, &)
|
||||
end
|
||||
|
||||
def validate_content_type!(content_type)
|
||||
return unless options.validate_content_type?
|
||||
return if allowed_content_type?(content_type)
|
||||
|
||||
raise SafeFetch::UnsupportedContentTypeError, "content-type not allowed: #{content_type}"
|
||||
end
|
||||
|
||||
def write_response_body(response, tempfile, bytes_written)
|
||||
response.read_body do |chunk|
|
||||
bytes_written += chunk.bytesize
|
||||
raise SafeFetch::FileTooLargeError, "exceeded #{options.effective_max_bytes} bytes" if bytes_written > options.effective_max_bytes
|
||||
|
||||
tempfile.write(chunk)
|
||||
end
|
||||
|
||||
bytes_written
|
||||
end
|
||||
|
||||
def allowed_content_type?(value)
|
||||
mime = normalized_content_type(value)
|
||||
return false if mime.blank?
|
||||
|
||||
options.allowed_content_type_prefixes.any? { |prefix| mime.start_with?(prefix) } ||
|
||||
options.allowed_content_types.include?(mime)
|
||||
end
|
||||
|
||||
def normalized_content_type(value)
|
||||
value.to_s.split(';').first&.strip&.downcase
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
class SafeFetch::PrivateNetworkRequest
|
||||
def initialize(options)
|
||||
@options = options
|
||||
end
|
||||
|
||||
def perform(&)
|
||||
url = options.url
|
||||
original_url = url
|
||||
original_uri = URI(url)
|
||||
|
||||
(SsrfFilter::DEFAULT_MAX_REDIRECTS + 1).times do
|
||||
uri = URI(url)
|
||||
validate_scheme!(uri)
|
||||
|
||||
response, next_url = fetch_once(uri, resolved_addresses(uri.hostname).sample.to_s, original_uri, &)
|
||||
return response if next_url.nil?
|
||||
|
||||
url = next_url
|
||||
end
|
||||
|
||||
raise SsrfFilter::TooManyRedirects, "Got #{SsrfFilter::DEFAULT_MAX_REDIRECTS} redirects fetching #{original_url}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :options
|
||||
|
||||
def validate_scheme!(uri)
|
||||
return if SsrfFilter::DEFAULT_SCHEME_WHITELIST.include?(uri.scheme)
|
||||
|
||||
raise SsrfFilter::InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{SsrfFilter::DEFAULT_SCHEME_WHITELIST}"
|
||||
end
|
||||
|
||||
def resolved_addresses(hostname)
|
||||
ip_addresses = options.resolver.call(hostname)
|
||||
raise SsrfFilter::UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty?
|
||||
|
||||
ip_addresses
|
||||
end
|
||||
|
||||
def fetch_once(uri, ip_address, original_uri, &)
|
||||
request = build_request(uri)
|
||||
strip_sensitive_headers!(request, original_uri, uri)
|
||||
validate_request!(request)
|
||||
|
||||
Net::HTTP.start(uri.hostname, uri.port, **http_options(uri, ip_address)) do |http|
|
||||
response = http.request(request, &)
|
||||
return response, redirect_location(response, uri)
|
||||
end
|
||||
end
|
||||
|
||||
def build_request(uri)
|
||||
request = SsrfFilter::VERB_MAP[options.method].new(uri)
|
||||
request['host'] = normalized_hostname(uri)
|
||||
|
||||
Array(options.request_options[:headers]).each { |header, value| request[header] = value }
|
||||
request.body = options.body if options.body
|
||||
options.request_options[:request_proc].call(request) if options.request_options[:request_proc].respond_to?(:call)
|
||||
|
||||
request
|
||||
end
|
||||
|
||||
def http_options(uri, ip_address)
|
||||
options.request_options[:http_options].merge(
|
||||
use_ssl: uri.scheme == 'https',
|
||||
ipaddr: ip_address
|
||||
)
|
||||
end
|
||||
|
||||
def strip_sensitive_headers!(request, original_uri, uri)
|
||||
return unless different_origin?(original_uri, uri)
|
||||
|
||||
options.request_options[:sensitive_headers].each { |header| request.delete(header) }
|
||||
end
|
||||
|
||||
def validate_request!(request)
|
||||
request.each do |header, value|
|
||||
next if header.count("\r\n").zero? && value.count("\r\n").zero?
|
||||
|
||||
raise SsrfFilter::CRLFInjection, "CRLF injection in header #{header} with value #{value}"
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_location(response, uri)
|
||||
return unless response.is_a?(Net::HTTPRedirection)
|
||||
|
||||
location = response['location']
|
||||
return "#{uri.scheme}://#{normalized_hostname(uri)}#{location}" if location&.start_with?('/')
|
||||
|
||||
location
|
||||
end
|
||||
|
||||
def normalized_hostname(uri)
|
||||
return uri.hostname if (uri.port == 80 && uri.scheme == 'http') || (uri.port == 443 && uri.scheme == 'https')
|
||||
|
||||
"#{uri.hostname}:#{uri.port}"
|
||||
end
|
||||
|
||||
def different_origin?(uri, other_uri)
|
||||
uri.scheme != other_uri.scheme || uri.hostname != other_uri.hostname || uri.port != other_uri.port
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
class SafeFetch::RequestOptions
|
||||
DEFAULTS = {
|
||||
method: :get,
|
||||
body: nil,
|
||||
max_bytes: nil,
|
||||
open_timeout: SafeFetch::DEFAULT_OPEN_TIMEOUT,
|
||||
read_timeout: SafeFetch::DEFAULT_READ_TIMEOUT,
|
||||
headers: nil,
|
||||
http_basic_authentication: nil,
|
||||
allowed_content_type_prefixes: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
|
||||
allowed_content_types: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPES,
|
||||
validate_content_type: true
|
||||
}.freeze
|
||||
|
||||
attr_reader :allowed_content_type_prefixes, :allowed_content_types, :body, :headers,
|
||||
:http_basic_authentication, :method, :open_timeout, :read_timeout, :uri, :url
|
||||
|
||||
def initialize(url:, **options)
|
||||
config = DEFAULTS.merge(options)
|
||||
@url = url
|
||||
@uri = parse_and_validate_url!(url)
|
||||
@method = normalize_method(config[:method])
|
||||
@body = config[:body]
|
||||
@max_bytes = config[:max_bytes]
|
||||
@open_timeout = config[:open_timeout]
|
||||
@read_timeout = config[:read_timeout]
|
||||
@headers = normalize_headers(config[:headers])
|
||||
@http_basic_authentication = config[:http_basic_authentication]
|
||||
@allowed_content_type_prefixes = Array(config[:allowed_content_type_prefixes])
|
||||
@allowed_content_types = Array(config[:allowed_content_types])
|
||||
@validate_content_type = config[:validate_content_type]
|
||||
end
|
||||
|
||||
def effective_max_bytes
|
||||
@effective_max_bytes ||= @max_bytes || default_max_bytes
|
||||
end
|
||||
|
||||
def filename
|
||||
@filename ||= File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def request_options
|
||||
{
|
||||
headers: headers,
|
||||
body: body,
|
||||
request_proc: request_proc,
|
||||
sensitive_headers: sensitive_headers,
|
||||
http_options: { open_timeout: open_timeout, read_timeout: read_timeout }
|
||||
}
|
||||
end
|
||||
|
||||
def validate_content_type?
|
||||
@validate_content_type
|
||||
end
|
||||
|
||||
def resolver
|
||||
SsrfFilter::DEFAULT_RESOLVER
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_max_bytes
|
||||
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
|
||||
limit_mb = SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
|
||||
limit_mb.megabytes
|
||||
end
|
||||
|
||||
def parse_and_validate_url!(value)
|
||||
parsed_uri = URI.parse(value)
|
||||
raise SafeFetch::InvalidUrlError, 'scheme must be http or https' unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||
raise SafeFetch::InvalidUrlError, 'missing host' if parsed_uri.host.blank?
|
||||
|
||||
parsed_uri
|
||||
end
|
||||
|
||||
def normalize_method(value)
|
||||
http_method = value.to_s.downcase.to_sym
|
||||
return http_method if SsrfFilter::VERB_MAP.key?(http_method)
|
||||
|
||||
raise SafeFetch::UnsupportedMethodError, "unsupported method: #{value}"
|
||||
end
|
||||
|
||||
def normalize_headers(value)
|
||||
value&.to_h
|
||||
end
|
||||
|
||||
def request_proc
|
||||
proc do |request|
|
||||
credentials = http_basic_authentication.presence || basic_authentication_for(request.uri)
|
||||
request.basic_auth(*credentials) if credentials.present?
|
||||
end
|
||||
end
|
||||
|
||||
def sensitive_headers
|
||||
SafeFetch::DEFAULT_SENSITIVE_HEADERS
|
||||
end
|
||||
|
||||
def basic_authentication_for(request_uri)
|
||||
uri_basic_authentication(request_uri) || original_uri_basic_authentication(request_uri)
|
||||
end
|
||||
|
||||
def original_uri_basic_authentication(request_uri)
|
||||
return unless same_origin?(request_uri, uri)
|
||||
|
||||
uri_basic_authentication(uri)
|
||||
end
|
||||
|
||||
def same_origin?(request_uri, other_uri)
|
||||
request_uri.scheme == other_uri.scheme && request_uri.hostname == other_uri.hostname && request_uri.port == other_uri.port
|
||||
end
|
||||
|
||||
def uri_basic_authentication(value)
|
||||
return if value.user.blank?
|
||||
|
||||
[
|
||||
URI.decode_uri_component(value.user),
|
||||
URI.decode_uri_component(value.password.to_s)
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,7 @@ class Seeders::AccountSeeder
|
||||
def perform!
|
||||
set_up_account
|
||||
seed_teams
|
||||
seed_custom_roles
|
||||
set_up_users
|
||||
seed_labels
|
||||
seed_canned_responses
|
||||
@@ -32,6 +33,7 @@ class Seeders::AccountSeeder
|
||||
@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
|
||||
@@ -40,6 +42,18 @@ class Seeders::AccountSeeder
|
||||
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)
|
||||
@@ -48,17 +62,34 @@ class Seeders::AccountSeeder
|
||||
|
||||
def set_up_users
|
||||
@account_data['users'].each do |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']}")
|
||||
AccountUser.create_with(role: (user['role'] || 'agent')).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
|
||||
next if user['team'].blank?
|
||||
|
||||
add_user_to_teams(user: user_record, teams: user['team'])
|
||||
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?
|
||||
@@ -66,6 +97,12 @@ class Seeders::AccountSeeder
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -61,41 +61,49 @@ users:
|
||||
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'
|
||||
@@ -111,6 +119,7 @@ users:
|
||||
email: 'lonny@paperlayer.test'
|
||||
team:
|
||||
- 'Warehouse'
|
||||
custom_role: 'Customer Support Lead'
|
||||
- name: 'Madge Madsen'
|
||||
gender: female
|
||||
email: 'madge@paperlayer.test'
|
||||
@@ -162,6 +171,7 @@ users:
|
||||
- name: 'Devon White'
|
||||
gender: male
|
||||
email: 'devon@paperlayer.test'
|
||||
custom_role: 'Escalation Handler'
|
||||
- name: 'Kendall'
|
||||
gender: male
|
||||
email: 'kendall@paperlayer.test'
|
||||
@@ -173,6 +183,39 @@ teams:
|
||||
- '💼 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'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# This rake task was added by annotate_rb gem.
|
||||
|
||||
# Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this
|
||||
if Rails.env.development? && ENV['ANNOTATERB_SKIP_ON_DB_TASKS'].nil?
|
||||
require 'annotate_rb'
|
||||
|
||||
AnnotateRb::Core.load_rake_tasks
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,81 @@
|
||||
# Migrate max_assignment_limit to Agent Capacity Policies
|
||||
#
|
||||
# Converts legacy per-inbox max_assignment_limit settings into
|
||||
# AgentCapacityPolicy records used by Assignment V2.
|
||||
#
|
||||
# Usage Examples:
|
||||
# # Migrate a single account
|
||||
# ACCOUNT_ID=1 bundle exec rake assignment_v2:migrate
|
||||
#
|
||||
# # Migrate all accounts in the installation
|
||||
# bundle exec rake assignment_v2:migrate
|
||||
#
|
||||
# Parameters:
|
||||
# ACCOUNT_ID: (optional) ID of the account to migrate. If omitted, migrates all accounts.
|
||||
#
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :assignment_v2 do
|
||||
desc 'Migrate max_assignment_limit inbox settings to agent capacity policies'
|
||||
task migrate: :environment do
|
||||
int_max = (2**31) - 1
|
||||
policy_name = 'Auto Assignment Capacity'
|
||||
|
||||
account_id = ENV.fetch('ACCOUNT_ID', nil)
|
||||
accounts = account_id.present? ? Account.where(id: account_id) : Account.all
|
||||
|
||||
if account_id.blank?
|
||||
print 'No ACCOUNT_ID specified. This will migrate ALL accounts. Continue? [y/N] '
|
||||
abort 'Aborted.' unless $stdin.gets.chomp.casecmp('y').zero?
|
||||
end
|
||||
|
||||
if account_id.present? && accounts.empty?
|
||||
puts "Error: Account with ID #{account_id} not found"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
total = accounts.count
|
||||
puts "Migrating assignment policies for #{total} account(s)..."
|
||||
puts "Started at: #{Time.current}"
|
||||
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
errored = 0
|
||||
|
||||
accounts.find_each do |account|
|
||||
inboxes_with_limit = account.inboxes.where("auto_assignment_config->>'max_assignment_limit' ~ '[1-9]'")
|
||||
|
||||
if inboxes_with_limit.empty?
|
||||
skipped += 1
|
||||
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — skipped (no inboxes with limit)"
|
||||
next
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
policy = AgentCapacityPolicy.find_or_create_by!(account: account, name: policy_name) do |p|
|
||||
p.description = 'Migrated from inbox settings'
|
||||
end
|
||||
|
||||
inboxes_with_limit.each do |inbox|
|
||||
next if InboxCapacityLimit.exists?(agent_capacity_policy_id: policy.id, inbox_id: inbox.id)
|
||||
|
||||
limit = [inbox.auto_assignment_config['max_assignment_limit'].to_i, int_max].min
|
||||
InboxCapacityLimit.create!(agent_capacity_policy: policy, inbox: inbox, conversation_limit: limit)
|
||||
end
|
||||
|
||||
member_user_ids = InboxMember.where(inbox_id: inboxes_with_limit.select(:id)).distinct.pluck(:user_id)
|
||||
account.account_users
|
||||
.where(user_id: member_user_ids, agent_capacity_policy_id: nil)
|
||||
.find_each { |au| au.update!(agent_capacity_policy_id: policy.id) }
|
||||
end
|
||||
|
||||
migrated += 1
|
||||
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — migrated"
|
||||
rescue StandardError => e
|
||||
errored += 1
|
||||
puts " [#{migrated + skipped + errored}/#{total}] Account #{account.id} — error: #{e.message}"
|
||||
end
|
||||
|
||||
puts "\nDone! Migrated: #{migrated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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'
|
||||
task :set_annotation_options do
|
||||
# You can override any of these by setting an environment variable of the
|
||||
# same name.
|
||||
Annotate.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
|
||||
|
||||
Annotate.load_tasks
|
||||
end
|
||||
@@ -2,10 +2,12 @@
|
||||
# https://github.com/rails/rails/issues/43906#issuecomment-1099992310
|
||||
task before_assets_precompile: :environment do
|
||||
# run a command which starts your packaging
|
||||
ENV['NODE_OPTIONS'] = '--openssl-legacy-provider'
|
||||
system('yarn')
|
||||
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 ['before_assets_precompile']
|
||||
Rake::Task['assets:precompile'].enhance %w[before_assets_precompile]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -18,7 +18,7 @@ db_namespace = namespace :db 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::Base.schema_format == :ruby
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,35 +2,6 @@ require 'rubygems/package'
|
||||
|
||||
namespace :ip_lookup do
|
||||
task setup: :environment do
|
||||
next if File.exist?(GeocoderConfiguration::LOOK_UP_DB)
|
||||
|
||||
ip_lookup_api_key = ENV.fetch('IP_LOOKUP_API_KEY', nil)
|
||||
if ip_lookup_api_key.blank?
|
||||
Rails.logger.info '[rake ip_lookup:setup] IP_LOOKUP_API_KEY empty. Skipping geoip database setup'
|
||||
next
|
||||
end
|
||||
|
||||
Rails.logger.info '[rake ip_lookup:setup] Fetch GeoLite2-City database'
|
||||
|
||||
begin
|
||||
base_url = 'https://download.maxmind.com/app/geoip_download'
|
||||
source_file = Down.download(
|
||||
"#{base_url}?edition_id=GeoLite2-City&suffix=tar.gz&license_key=#{ip_lookup_api_key}"
|
||||
)
|
||||
|
||||
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open(source_file))
|
||||
tar_extract.rewind
|
||||
|
||||
tar_extract.each do |entry|
|
||||
next unless entry.full_name.include?('GeoLite2-City.mmdb') && entry.file?
|
||||
|
||||
File.open GeocoderConfiguration::LOOK_UP_DB, 'wb' do |f|
|
||||
f.print entry.read
|
||||
end
|
||||
end
|
||||
Rails.logger.info '[rake ip_lookup:setup] Fetch complete'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[rake ip_lookup:setup] #{e.message}"
|
||||
end
|
||||
Geocoder::SetupService.new.perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,14 @@
|
||||
namespace :onboarding do
|
||||
desc 'Reset onboarding for an account (triggers the onboarding flow again). Usage: rake onboarding:reset[account_id]'
|
||||
task :reset, [:account_id] => :environment do |_task, args|
|
||||
abort 'Error: Please provide an account ID' if args[:account_id].blank?
|
||||
|
||||
account = Account.find_by(id: args[:account_id])
|
||||
abort "Error: Account with ID '#{args[:account_id]}' not found" unless account
|
||||
|
||||
account.custom_attributes['onboarding_step'] = 'account_details'
|
||||
account.save!
|
||||
|
||||
puts "Onboarding has been reset for account '#{account.name}' (ID: #{account.id})"
|
||||
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.last_activity_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
|
||||
@@ -0,0 +1,268 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :reporting_events_rollup do
|
||||
desc 'Backfill rollup table from historical reporting events'
|
||||
task backfill: :environment do
|
||||
ReportingEventsRollupBackfill.new.run
|
||||
end
|
||||
end
|
||||
|
||||
class ReportingEventsRollupBackfill # rubocop:disable Metrics/ClassLength
|
||||
def run
|
||||
print_header
|
||||
account = prompt_account
|
||||
timezone = resolve_timezone(account)
|
||||
first_event, last_event = discover_events(account)
|
||||
start_date, end_date, total_days = resolve_date_range(account, timezone, first_event, last_event)
|
||||
dry_run = prompt_dry_run?
|
||||
print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
|
||||
return if dry_run
|
||||
|
||||
confirm_and_execute(account, start_date, end_date, total_days)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def print_header
|
||||
puts ''
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Reporting Events Rollup Backfill', :bold, :cyan)
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Plan:', :bold, :yellow)
|
||||
puts '1. Ensure account.reporting_timezone is set before running this task.'
|
||||
puts '2. Wait for the current day to end in that account timezone.'
|
||||
puts '3. Run backfill for closed days only (today is skipped by default).'
|
||||
puts '4. Verify parity, then enable reporting_events_rollup read path.'
|
||||
puts ''
|
||||
puts color('Note:', :bold, :yellow)
|
||||
puts '- This task always uses account.reporting_timezone.'
|
||||
puts '- Default range is first event day -> yesterday (in account timezone).'
|
||||
puts ''
|
||||
end
|
||||
|
||||
def prompt_account
|
||||
print 'Enter Account ID: '
|
||||
account_id = $stdin.gets.chomp
|
||||
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
|
||||
|
||||
puts color("Found account: #{account.name}", :gray)
|
||||
puts ''
|
||||
account
|
||||
end
|
||||
|
||||
def resolve_timezone(account)
|
||||
timezone = account.reporting_timezone
|
||||
abort color("Error: Account #{account.id} must have reporting_timezone set", :red, :bold) if timezone.blank?
|
||||
abort color("Error: Account #{account.id} has invalid reporting_timezone '#{timezone}'", :red, :bold) if ActiveSupport::TimeZone[timezone].blank?
|
||||
|
||||
puts color("Using account reporting timezone: #{timezone}", :gray)
|
||||
puts ''
|
||||
timezone
|
||||
end
|
||||
|
||||
def discover_events(account)
|
||||
first_event = account.reporting_events.order(:created_at).first
|
||||
last_event = account.reporting_events.order(:created_at).last
|
||||
|
||||
if first_event.nil?
|
||||
puts ''
|
||||
puts "No reporting events found for account #{account.id}"
|
||||
puts 'Nothing to backfill.'
|
||||
exit(0)
|
||||
end
|
||||
|
||||
[first_event, last_event]
|
||||
end
|
||||
|
||||
def resolve_date_range(account, timezone, first_event, last_event)
|
||||
dates = discovered_dates(timezone, first_event, last_event)
|
||||
print_discovered_date_range(account, dates)
|
||||
build_date_range(dates)
|
||||
end
|
||||
|
||||
def prompt_dry_run?
|
||||
print 'Dry run? (y/N): '
|
||||
input = $stdin.gets.chomp.downcase
|
||||
puts ''
|
||||
%w[y yes].include?(input)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/ParameterLists
|
||||
def print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
|
||||
zone = ActiveSupport::TimeZone[timezone]
|
||||
print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run)
|
||||
|
||||
return unless dry_run
|
||||
|
||||
puts color("DRY RUN MODE: Would process #{total_days} days", :yellow, :bold)
|
||||
puts "Would use account reporting_timezone '#{timezone}'"
|
||||
puts 'Run without dry run to execute backfill'
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
def print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run) # rubocop:disable Metrics/ParameterLists
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Backfill Plan Summary', :bold, :cyan)
|
||||
puts color('=' * 70, :cyan)
|
||||
puts "Account: #{account.name} (ID: #{account.id})"
|
||||
puts "Timezone: #{timezone}"
|
||||
puts "Date Range: #{start_date} to #{end_date} (#{total_days} days)"
|
||||
puts "First Event: #{format_event_time(first_event, zone)}"
|
||||
puts "Last Event: #{format_event_time(last_event, zone)}"
|
||||
puts "Dry Run: #{dry_run ? 'YES (no data will be written)' : 'NO'}"
|
||||
puts color('=' * 70, :cyan)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def format_event_time(event, zone)
|
||||
event.created_at.in_time_zone(zone).strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
end
|
||||
|
||||
def discovered_dates(timezone, first_event, last_event)
|
||||
tz = ActiveSupport::TimeZone[timezone]
|
||||
discovered_start = first_event.created_at.in_time_zone(tz).to_date
|
||||
discovered_end = last_event.created_at.in_time_zone(tz).to_date
|
||||
|
||||
{
|
||||
discovered_start: discovered_start,
|
||||
discovered_end: discovered_end,
|
||||
discovered_days: (discovered_end - discovered_start).to_i + 1,
|
||||
default_end: [discovered_end, Time.current.in_time_zone(tz).to_date - 1.day].min
|
||||
}
|
||||
end
|
||||
|
||||
def print_discovered_date_range(account, dates)
|
||||
message = "Discovered date range: #{dates[:discovered_start]} to #{dates[:discovered_end]} " \
|
||||
"(#{dates[:discovered_days]} days) [Account: #{account.name}]"
|
||||
puts color(message, :gray)
|
||||
puts color("Default end date (excluding today): #{dates[:default_end]}", :gray)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def build_date_range(dates)
|
||||
start_date = dates[:discovered_start]
|
||||
end_date = dates[:default_end]
|
||||
total_days = (end_date - start_date).to_i + 1
|
||||
|
||||
abort_no_closed_days if total_days <= 0
|
||||
|
||||
[start_date, end_date, total_days]
|
||||
end
|
||||
|
||||
def abort_no_closed_days
|
||||
puts 'No closed days available to backfill in the default range.'
|
||||
exit(0)
|
||||
end
|
||||
|
||||
def confirm_and_execute(account, start_date, end_date, total_days)
|
||||
if total_days > 730
|
||||
puts color("WARNING: Large backfill detected (#{total_days} days / #{(total_days / 365.0).round(1)} years)", :yellow, :bold)
|
||||
puts ''
|
||||
end
|
||||
|
||||
print 'Proceed with backfill? (y/N): '
|
||||
confirm = $stdin.gets.chomp.downcase
|
||||
abort 'Backfill cancelled' unless %w[y yes].include?(confirm)
|
||||
|
||||
puts ''
|
||||
execute_backfill(account, start_date, end_date, total_days)
|
||||
end
|
||||
|
||||
def execute_backfill(account, start_date, end_date, total_days)
|
||||
puts 'Processing dates...'
|
||||
puts ''
|
||||
|
||||
start_time = Time.current
|
||||
days_processed = 0
|
||||
|
||||
(start_date..end_date).each do |date|
|
||||
ReportingEvents::BackfillService.backfill_date(account, date)
|
||||
days_processed += 1
|
||||
percentage = (days_processed.to_f / total_days * 100).round(1)
|
||||
print "\r#{date} | #{days_processed}/#{total_days} days | #{percentage}% "
|
||||
$stdout.flush
|
||||
end
|
||||
|
||||
print_success(account, days_processed, total_days, Time.current - start_time)
|
||||
rescue StandardError => e
|
||||
print_failure(e, days_processed, total_days)
|
||||
else
|
||||
prompt_enable_rollup_read_path(account)
|
||||
end
|
||||
|
||||
def print_success(account, days_processed, _total_days, elapsed_time)
|
||||
puts "\n\n"
|
||||
puts color('=' * 70, :green)
|
||||
puts color('BACKFILL COMPLETE', :bold, :green)
|
||||
puts color('=' * 70, :green)
|
||||
puts "Total Days Processed: #{days_processed}"
|
||||
puts "Total Time: #{elapsed_time.round(2)} seconds"
|
||||
puts "Average per Day: #{(elapsed_time / days_processed).round(3)} seconds"
|
||||
puts ''
|
||||
puts 'Next steps:'
|
||||
puts '1. Verify parity before enabling the reporting_events_rollup read path.'
|
||||
puts '2. Verify rollups in database:'
|
||||
puts " ReportingEventsRollup.where(account_id: #{account.id}).count"
|
||||
puts '3. Test reports to compare rollup vs raw performance'
|
||||
puts color('=' * 70, :green)
|
||||
end
|
||||
|
||||
def prompt_enable_rollup_read_path(account)
|
||||
if account.feature_enabled?(:report_rollup)
|
||||
puts color('report_rollup is already enabled for this account.', :yellow, :bold)
|
||||
return
|
||||
end
|
||||
|
||||
print 'Enable report_rollup read path now? Only do this after parity verification. (y/N): '
|
||||
confirm = $stdin.gets.to_s.chomp.downcase
|
||||
puts ''
|
||||
return unless %w[y yes].include?(confirm)
|
||||
|
||||
account.enable_features!('report_rollup')
|
||||
puts color("Enabled report_rollup for account #{account.id}", :green, :bold)
|
||||
end
|
||||
|
||||
def print_failure(error, days_processed, total_days)
|
||||
puts "\n\n"
|
||||
puts color('=' * 70, :red)
|
||||
puts color('BACKFILL FAILED', :bold, :red)
|
||||
puts color('=' * 70, :red)
|
||||
print_error_details(error)
|
||||
print_progress(days_processed, total_days)
|
||||
exit(1)
|
||||
end
|
||||
|
||||
def print_error_details(error)
|
||||
puts color("Error: #{error.class.name} - #{error.message}", :red, :bold)
|
||||
puts ''
|
||||
puts 'Stack trace:'
|
||||
puts error.backtrace.first(10).map { |line| " #{line}" }.join("\n")
|
||||
puts ''
|
||||
end
|
||||
|
||||
def print_progress(days_processed, total_days)
|
||||
percentage = (days_processed.to_f / total_days * 100).round(1)
|
||||
puts "Processed: #{days_processed}/#{total_days} days (#{percentage}%)"
|
||||
puts color('=' * 70, :red)
|
||||
end
|
||||
|
||||
ANSI_COLORS = {
|
||||
reset: "\e[0m",
|
||||
bold: "\e[1m",
|
||||
red: "\e[31m",
|
||||
green: "\e[32m",
|
||||
yellow: "\e[33m",
|
||||
cyan: "\e[36m",
|
||||
gray: "\e[90m"
|
||||
}.freeze
|
||||
|
||||
def color(text, *styles)
|
||||
return text unless $stdout.tty?
|
||||
|
||||
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
|
||||
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :reporting_events_rollup do
|
||||
desc 'Interactively set account.reporting_timezone and show recommended backfill run times'
|
||||
task set_timezone: :environment do
|
||||
ReportingEventsRollupTimezoneSetup.new.run
|
||||
end
|
||||
end
|
||||
|
||||
class ReportingEventsRollupTimezoneSetup
|
||||
def run
|
||||
print_header
|
||||
account = prompt_account
|
||||
print_current_timezone(account)
|
||||
timezone = prompt_timezone
|
||||
confirm_and_update(account, timezone)
|
||||
print_next_steps(account, timezone)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def print_header
|
||||
puts ''
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Reporting Events Rollup Timezone Setup', :bold, :cyan)
|
||||
puts color('=' * 70, :cyan)
|
||||
puts color('Help:', :bold, :yellow)
|
||||
puts '1. This task writes a valid account.reporting_timezone.'
|
||||
puts '2. Backfill uses this timezone and skips today by default.'
|
||||
puts '3. Run backfill only after the account timezone day closes.'
|
||||
puts ''
|
||||
end
|
||||
|
||||
def prompt_account
|
||||
print 'Enter Account ID: '
|
||||
account_id = $stdin.gets.chomp
|
||||
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
|
||||
|
||||
puts color("Found account: #{account.name}", :gray)
|
||||
puts ''
|
||||
account
|
||||
end
|
||||
|
||||
def print_current_timezone(account)
|
||||
current_timezone = account.reporting_timezone.presence || '(not set)'
|
||||
puts color("Current reporting_timezone: #{current_timezone}", :gray)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def prompt_timezone
|
||||
loop do
|
||||
print 'Enter UTC offset to pick timezone (e.g., +5:30, -8, 0): '
|
||||
offset_input = $stdin.gets.chomp
|
||||
abort color('Error: UTC offset is required', :red, :bold) if offset_input.blank?
|
||||
|
||||
matching_zones = find_matching_zones(offset_input)
|
||||
abort color("Error: No timezones found for offset '#{offset_input}'", :red, :bold) if matching_zones.empty?
|
||||
|
||||
display_matching_zones(matching_zones, offset_input)
|
||||
timezone = select_timezone(matching_zones)
|
||||
return timezone if timezone.present?
|
||||
end
|
||||
end
|
||||
|
||||
def find_matching_zones(offset_input)
|
||||
total_seconds = utc_offset_in_seconds(offset_input)
|
||||
return [] unless total_seconds
|
||||
|
||||
ActiveSupport::TimeZone.all.select { |tz| tz.utc_offset == total_seconds }
|
||||
end
|
||||
|
||||
def utc_offset_in_seconds(offset_input)
|
||||
normalized = offset_input.strip
|
||||
return unless normalized.match?(/\A[+-]?\d{1,2}(:\d{2})?\z/)
|
||||
|
||||
sign = normalized.start_with?('-') ? -1 : 1
|
||||
raw = normalized.delete_prefix('+').delete_prefix('-')
|
||||
hours_part, minutes_part = raw.split(':', 2)
|
||||
|
||||
hours = Integer(hours_part, 10)
|
||||
minutes = Integer(minutes_part || '0', 10)
|
||||
return unless minutes.between?(0, 59)
|
||||
|
||||
total_minutes = (hours * 60) + minutes
|
||||
return if total_minutes > max_utc_offset_minutes(sign)
|
||||
|
||||
sign * total_minutes * 60
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def max_utc_offset_minutes(sign)
|
||||
sign.negative? ? 12 * 60 : 14 * 60
|
||||
end
|
||||
|
||||
def display_matching_zones(zones, offset_input)
|
||||
puts ''
|
||||
puts color("Timezones matching UTC#{offset_input}:", :yellow, :bold)
|
||||
puts ''
|
||||
zones.each_with_index do |tz, index|
|
||||
puts " #{index + 1}. #{tz.name} (#{tz.tzinfo.identifier})"
|
||||
end
|
||||
puts ' 0. Re-enter UTC offset'
|
||||
puts ''
|
||||
end
|
||||
|
||||
def select_timezone(zones)
|
||||
print "Select timezone (1-#{zones.size}, 0 to go back): "
|
||||
selection = $stdin.gets.chomp.to_i
|
||||
return if selection.zero?
|
||||
|
||||
abort color('Error: Invalid selection', :red, :bold) if selection < 1 || selection > zones.size
|
||||
|
||||
timezone = zones[selection - 1].tzinfo.identifier
|
||||
puts color("Selected timezone: #{timezone}", :gray)
|
||||
puts ''
|
||||
timezone
|
||||
end
|
||||
|
||||
def confirm_and_update(account, timezone)
|
||||
print "Update account #{account.id} reporting_timezone to '#{timezone}'? (y/N): "
|
||||
confirm = $stdin.gets.chomp.downcase
|
||||
abort 'Timezone setup cancelled' unless %w[y yes].include?(confirm)
|
||||
|
||||
account.update!(reporting_timezone: timezone)
|
||||
puts ''
|
||||
puts color("Updated reporting_timezone for account '#{account.name}' to '#{timezone}'", :green, :bold)
|
||||
puts ''
|
||||
end
|
||||
|
||||
def print_next_steps(account, timezone)
|
||||
run_times = recommended_run_times(timezone)
|
||||
print_next_steps_header
|
||||
print_next_steps_schedule(timezone, run_times)
|
||||
print_next_steps_backfill(account)
|
||||
puts color('=' * 70, :green)
|
||||
end
|
||||
|
||||
def print_next_steps_header
|
||||
puts color('=' * 70, :green)
|
||||
puts color('Next Steps', :bold, :green)
|
||||
puts color('=' * 70, :green)
|
||||
end
|
||||
|
||||
def print_next_steps_schedule(timezone, run_times)
|
||||
puts "1. Wait for today's day-boundary to pass in #{timezone}."
|
||||
puts '2. Recommended earliest backfill start time:'
|
||||
puts " - #{timezone}: #{format_time(run_times[:account_tz])}"
|
||||
puts " - UTC: #{format_time(run_times[:utc])}"
|
||||
puts " - IST: #{format_time(run_times[:ist])}"
|
||||
puts " - PCT/PT: #{format_time(run_times[:pct])}"
|
||||
end
|
||||
|
||||
def print_next_steps_backfill(account)
|
||||
puts '3. Run backfill:'
|
||||
puts ' bundle exec rake reporting_events_rollup:backfill'
|
||||
puts "4. Backfill will use account.reporting_timezone and skip today by default for account #{account.id}."
|
||||
end
|
||||
|
||||
def recommended_run_times(timezone)
|
||||
account_zone = ActiveSupport::TimeZone[timezone]
|
||||
next_day = Time.current.in_time_zone(account_zone).to_date + 1.day
|
||||
account_time = account_zone.parse(next_day.to_s) + 30.minutes
|
||||
|
||||
{
|
||||
account_tz: account_time,
|
||||
utc: account_time.in_time_zone('UTC'),
|
||||
ist: account_time.in_time_zone('Asia/Kolkata'),
|
||||
pct: account_time.in_time_zone('Pacific Time (US & Canada)')
|
||||
}
|
||||
end
|
||||
|
||||
def format_time(time)
|
||||
time.strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
end
|
||||
|
||||
ANSI_COLORS = {
|
||||
reset: "\e[0m",
|
||||
bold: "\e[1m",
|
||||
red: "\e[31m",
|
||||
green: "\e[32m",
|
||||
yellow: "\e[33m",
|
||||
cyan: "\e[36m",
|
||||
gray: "\e[90m"
|
||||
}.freeze
|
||||
|
||||
def color(text, *styles)
|
||||
return text unless $stdout.tty?
|
||||
|
||||
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
|
||||
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# Refresh the RubyLLM model registry from models.dev and configured providers.
|
||||
# Updates config/llm_models.json so new models are available without a gem upgrade.
|
||||
#
|
||||
# Usage:
|
||||
# bundle exec rake ruby_llm:refresh_models
|
||||
#
|
||||
# Run this when new models are released, commit the updated config/llm_models.json.
|
||||
namespace :ruby_llm do
|
||||
desc 'Refresh RubyLLM model registry from models.dev'
|
||||
task refresh_models: :environment do
|
||||
registry_path = Rails.root.join('config/llm_models.json').to_s
|
||||
puts 'Refreshing RubyLLM model registry...'
|
||||
RubyLLM.models.refresh!
|
||||
RubyLLM.models.save_to_json(registry_path)
|
||||
puts "RubyLLM model registry updated with #{RubyLLM.models.all.size} models at #{registry_path}"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user