Merge branch 'develop' into fixes/annotaterb

This commit is contained in:
Sojan Jose
2026-03-23 20:35:25 -07:00
committed by GitHub
2886 changed files with 135007 additions and 56978 deletions
+191
View File
@@ -0,0 +1,191 @@
class Captain::BaseTaskService
include Integrations::LlmInstrumentation
include Captain::ToolInstrumentation
# gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters
# sticking with 120000 to be safe
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
# Prepend enterprise module to subclasses when they're defined.
# This ensures the enterprise perform wrapper is applied even when
# subclasses define their own perform method, since prepend puts
# the module before the class in the ancestor chain.
def self.inherited(subclass)
super
subclass.prepend_mod_with('Captain::BaseTaskService')
end
pattr_initialize [:account!, { conversation_display_id: nil }]
private
def event_name
raise NotImplementedError, "#{self.class} must implement #event_name"
end
def conversation
@conversation ||= account.conversations.find_by(display_id: conversation_display_id)
end
def api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1"
end
def make_api_call(model:, messages:, 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: [])
Llm::Config.with_api_key(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
ChatwootExceptionTracker.new(e, account: account).capture_exception
{ 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
def api_key_configured?
api_key.present?
end
def api_key
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
end
def openai_hook
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
end
def system_api_key
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def prompt_from_file(file_name)
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
end
# Follow-up context for client-side refinement
def build_follow_up_context?
# FollowUpService should return its own updated context
!is_a?(Captain::FollowUpService)
end
def build_follow_up_context(messages, response)
{
event_name: event_name,
original_context: extract_original_context(messages),
last_response: response[:message],
conversation_history: [],
channel_type: conversation&.inbox&.channel_type
}
end
def extract_original_context(messages)
# Get the most recent user message for follow-up context
user_msg = messages.reverse.find { |m| m[:role] == 'user' }
user_msg ? user_msg[:content] : nil
end
end
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
@@ -0,0 +1,66 @@
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
end
+106
View File
@@ -0,0 +1,106 @@
class Captain::FollowUpService < Captain::BaseTaskService
pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
ALLOWED_EVENT_NAMES = %w[
professional
casual
friendly
confident
straightforward
fix_spelling_grammar
improve
summarize
reply_suggestion
label_suggestion
].freeze
def perform
return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
# Build context-aware system prompt
system_prompt = build_follow_up_system_prompt(follow_up_context)
# Build full message array (convert history from string keys to symbol keys)
history = follow_up_context['conversation_history'].to_a.map do |msg|
{ role: msg['role'], content: msg['content'] }
end
messages = [
{ role: 'system', content: system_prompt },
{ role: 'user', content: follow_up_context['original_context'] },
{ role: 'assistant', content: follow_up_context['last_response'] },
*history,
{ role: 'user', content: user_message }
]
response = make_api_call(model: GPT_MODEL, messages: messages)
return response if response[:error]
response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
end
private
def build_follow_up_system_prompt(session_data)
action_context = describe_previous_action(session_data['event_name'])
<<~PROMPT
You just performed a #{action_context} action for a customer support agent.
Your job now is to help them refine the result based on their feedback.
Be concise and focused on their specific request.
Output only the reply, no preamble, tags, or explanation.
PROMPT
end
def describe_previous_action(event_name)
case event_name
when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
"tone rewrite (#{event_name})"
when 'fix_spelling_grammar'
'spelling and grammar correction'
when 'improve'
'message improvement'
when 'summarize'
'conversation summary'
when 'reply_suggestion'
'reply suggestion'
when 'label_suggestion'
'label suggestion'
else
event_name
end
end
def valid_follow_up_context?
return false unless follow_up_context.is_a?(Hash)
return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
required_keys = %w[event_name original_context last_response]
required_keys.all? { |key| follow_up_context[key].present? }
end
def update_follow_up_context(user_msg, assistant_msg)
updated_history = follow_up_context['conversation_history'].to_a + [
{ 'role' => 'user', 'content' => user_msg },
{ 'role' => 'assistant', 'content' => assistant_msg }
]
{
'event_name' => follow_up_context['event_name'],
'original_context' => follow_up_context['original_context'],
'last_response' => assistant_msg,
'conversation_history' => updated_history,
'channel_type' => follow_up_context['channel_type']
}
end
def instrumentation_metadata
{
channel_type: conversation&.inbox&.channel_type || follow_up_context['channel_type']
}.compact
end
def event_name
'follow_up'
end
end
+93
View File
@@ -0,0 +1,93 @@
class Captain::LabelSuggestionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
# Check cache first
cached_response = read_from_cache
return cached_response if cached_response.present?
# Build content
content = labels_with_messages
return nil if content.blank?
# Make API call
response = make_api_call(
model: GPT_MODEL, # TODO: Use separate model for label suggestion
messages: [
{ role: 'system', content: prompt_from_file('label_suggestion') },
{ role: 'user', content: content }
]
)
return response if response[:error].present?
# Clean up response
result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
# Cache successful result
write_to_cache(result)
result
end
private
def cache_key
return nil unless conversation
format(
::Redis::Alfred::OPENAI_CONVERSATION_KEY,
event_name: 'label_suggestion',
conversation_id: conversation.id,
updated_at: conversation.last_activity_at.to_i
)
end
def read_from_cache
return nil unless cache_key
cached = Redis::Alfred.get(cache_key)
JSON.parse(cached, symbolize_names: true) if cached.present?
rescue JSON::ParserError
nil
end
def write_to_cache(response)
Redis::Alfred.setex(cache_key, response.to_json) if cache_key
end
def labels_with_messages
return nil unless valid_conversation?(conversation)
labels = account.labels.pluck(:title).join(', ')
messages = format_messages_as_string(start_from: labels.length)
return nil if messages.blank? || labels.blank?
"Messages:\n#{messages}\nLabels:\n#{labels}"
end
def format_messages_as_string(start_from: 0)
messages = conversation_messages(start_from: start_from)
messages.map do |msg|
sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent'
"#{sender_type}: #{msg[:content]}\n"
end.join
end
def valid_conversation?(conversation)
return false if conversation.nil?
return false if conversation.messages.incoming.count < 3
return false if conversation.messages.count > 100
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
true
end
def event_name
'label_suggestion'
end
def build_follow_up_context?
false
end
end
+42
View File
@@ -0,0 +1,42 @@
class Captain::ReplySuggestionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!, :user!]
def perform
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: formatted_conversation }
]
)
end
private
def system_prompt
template = prompt_from_file('reply')
render_liquid_template(template, prompt_variables)
end
def prompt_variables
{
'channel_type' => conversation.inbox.channel_type,
'agent_name' => user.name,
'agent_signature' => user.message_signature.presence
}
end
def render_liquid_template(template_content, variables = {})
Liquid::Template.parse(template_content).render(variables)
end
def formatted_conversation
LlmFormatter::ConversationLlmFormatter.new(conversation).format(token_limit: TOKEN_LIMIT)
end
def event_name
'reply_suggestion'
end
end
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
+59
View File
@@ -0,0 +1,59 @@
class Captain::RewriteService < Captain::BaseTaskService
pattr_initialize [:account!, :content!, :operation!, { conversation_display_id: nil }]
TONE_OPERATIONS = %i[casual professional friendly confident straightforward].freeze
ALLOWED_OPERATIONS = (%i[fix_spelling_grammar improve] + TONE_OPERATIONS).freeze
def perform
operation_sym = operation.to_sym
raise ArgumentError, "Invalid operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation_sym)
send(operation_sym)
end
TONE_OPERATIONS.each do |tone|
define_method(tone) do
call_llm_with_prompt(tone_rewrite_prompt(tone.to_s))
end
end
private
def fix_spelling_grammar
call_llm_with_prompt(prompt_from_file('fix_spelling_grammar'))
end
def improve
template = prompt_from_file('improve')
system_prompt = render_liquid_template(template, {
'conversation_context' => conversation.to_llm_text(include_contact_details: true),
'draft_message' => content
})
call_llm_with_prompt(system_prompt, content)
end
def call_llm_with_prompt(system_content, user_content = content)
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: system_content },
{ role: 'user', content: user_content }
]
)
end
def render_liquid_template(template_content, variables = {})
Liquid::Template.parse(template_content).render(variables)
end
def tone_rewrite_prompt(tone)
template = prompt_from_file('tone_rewrite')
render_liquid_template(template, 'tone' => tone)
end
def event_name
operation
end
end
+19
View File
@@ -0,0 +1,19 @@
class Captain::SummaryService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
]
)
end
private
def event_name
'summarize'
end
end
+60
View File
@@ -0,0 +1,60 @@
module Captain::ToolInstrumentation
extend ActiveSupport::Concern
include Integrations::LlmInstrumentationConstants
private
# Custom instrumentation for tool flows - outputs just the message (not full hash)
def instrument_tool_session(params)
return yield unless ChatwootApp.otel_enabled?
response = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
set_tool_session_attributes(span, params)
response = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
end
response
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception
executed ? response : yield
end
def set_tool_session_attributes(span, params)
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
end
def set_tool_session_error_attributes(span, response)
error = response[:error] || response['error']
return if error.blank?
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span|
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
end
rescue StandardError => e
Rails.logger.warn "Failed to record generation: #{e.message}"
end
def format_chat_messages(chat)
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
end
end
+4
View File
@@ -21,6 +21,10 @@ module ChatwootApp
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
+32 -23
View File
@@ -1,12 +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
CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".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
@@ -15,7 +33,7 @@ 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
@@ -68,7 +86,7 @@ class ChatwootHub
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}"
@@ -80,7 +98,7 @@ 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
@@ -89,32 +107,23 @@ class ChatwootHub
def self.send_push(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def self.get_captain_settings(account)
info = {
installation_identifier: installation_identifier,
chatwoot_account_id: account.id,
account_name: account.name
}
HTTParty.post(CAPTAIN_ACCOUNTS_URL,
body: info.to_json,
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' })
end
def self.emit_event(event_name, event_data)
return if ENV['DISABLE_TELEMETRY']
info = { event_name: event_name, event_data: event_data }
RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
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')
+19
View File
@@ -0,0 +1,19 @@
module CustomExceptions::Pdf
class UploadError < CustomExceptions::Base
def initialize(message = 'PDF upload failed')
super(message)
end
end
class ValidationError < CustomExceptions::Base
def initialize(message = 'PDF validation failed')
super(message)
end
end
class FaqGenerationError < CustomExceptions::Base
def initialize(message = 'PDF FAQ generation failed')
super(message)
end
end
end
@@ -1,25 +0,0 @@
module CustomExceptions
class PdfProcessingError < Base
def initialize(message = 'PDF processing failed')
super(message)
end
end
class PdfUploadError < PdfProcessingError
def initialize(message = 'PDF upload failed')
super(message)
end
end
class PdfValidationError < PdfProcessingError
def initialize(message = 'PDF validation failed')
super(message)
end
end
class PdfFaqGenerationError < PdfProcessingError
def initialize(message = 'PDF FAQ generation failed')
super(message)
end
end
end
+2
View File
@@ -21,6 +21,8 @@ module Events::Types
# 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'
-6
View File
@@ -86,12 +86,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"
+4
View File
@@ -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
@@ -11,7 +11,7 @@ class Integrations::GoogleTranslate::ProcessorService
response = client.translate_text(
contents: [content],
target_language_code: target_language,
target_language_code: bcp47_language_code,
parent: "projects/#{hook.settings['project_id']}",
mime_type: mime_type
)
@@ -23,6 +23,10 @@ class Integrations::GoogleTranslate::ProcessorService
private
def bcp47_language_code
target_language.tr('_', '-')
end
def email_channel?
message&.inbox&.email?
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
+5 -1
View File
@@ -77,6 +77,10 @@ class Integrations::Linear::ProcessorService
end
def linear_client
@linear_client ||= Linear.new(linear_hook.access_token)
@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
+2 -1
View File
@@ -7,7 +7,8 @@ class Integrations::LlmBaseService
# 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[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
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!]
+3 -1
View File
@@ -30,7 +30,6 @@ module Integrations::LlmInstrumentation
result = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
set_request_attributes(span, params)
set_metadata_attributes(span, params)
# By default, the input and output of a trace are set from the root observation
@@ -38,6 +37,7 @@ module Integrations::LlmInstrumentation
result = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
set_error_attributes(span, result) if result.is_a?(Hash)
result
end
rescue StandardError => e
@@ -51,9 +51,11 @@ module Integrations::LlmInstrumentation
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
set_error_attributes(span, result) if result.is_a?(Hash)
result
end
end
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
end
def set_usage_metrics(span, result)
@@ -26,6 +26,7 @@ module Integrations::LlmInstrumentationConstants
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
end
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@pending_tool_spans ||= []
@@ -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.
Reply in the user's language, as a markdown of the following format.
**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,138 +0,0 @@
class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
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)
content = message.content_for_llm
if valid_message?(content, character_count)
add_message_to_list(message, messages, in_array_format, content)
character_count += content.length
[character_count, true]
else
[character_count, false]
end
end
def valid_message?(content, character_count)
content.present? && character_count + content.length <= TOKEN_LIMIT
end
def add_message_to_list(message, messages, in_array_format, content)
formatted_message = format_message(message, in_array_format, content)
messages.prepend(formatted_message)
end
def init_messages_body(in_array_format)
in_array_format ? [] : ''
end
def format_message(message, in_array_format, content)
in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
end
def format_message_in_array(message, content)
{ role: (message.incoming? ? 'user' : 'assistant'), content: content }
end
def format_message_in_string(message, content)
sender_type = message.incoming? ? 'Customer' : 'Agent'
"#{sender_type} #{message.sender&.name} : #{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')
@@ -102,7 +102,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
def send_message
post_message if message_content.present?
upload_files if message.attachments.any?
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
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!
+22 -4
View File
@@ -27,6 +27,10 @@ module Integrations::Slack::SlackMessageHelper
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,
@@ -34,7 +38,8 @@ module Integrations::Slack::SlackMessageHelper
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
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!
@@ -81,9 +86,22 @@ 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 private_note?
+1
View File
@@ -9,6 +9,7 @@ module Limits
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
+7 -2
View File
@@ -3,8 +3,9 @@ class Linear
REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze
PRIORITY_LEVELS = (0..4).to_a
def initialize(access_token)
def initialize(access_token, refresh_token: nil)
@access_token = access_token
@refresh_token = refresh_token
raise ArgumentError, 'Missing Credentials' if access_token.blank?
end
@@ -79,9 +80,13 @@ class Linear
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: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
body: { token: token, token_type_hint: token_type_hint }
)
response.success?
end
+2 -5
View File
@@ -2,13 +2,10 @@ module Linear::Mutations
def self.graphql_value(value)
case value
when String
# Strings must be enclosed in double quotes
"\"#{value.gsub("\n", '\\n')}\""
value.to_json
when Array
# Arrays need to be recursively converted
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
else
# Other types (numbers, booleans) can be directly converted to strings
value.to_s
end
end
@@ -47,7 +44,7 @@ module Linear::Mutations
<<~GRAPHQL
mutation {
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
attachmentLinkURL(url: #{graphql_value(link)}, issueId: #{graphql_value(issue_id)}, title: #{graphql_value(title)}#{user_params_str}) {
success
attachment {
id
+1 -1
View File
@@ -48,7 +48,7 @@ module Linear::Queries
def self.search_issue(term)
<<~GRAPHQL
query {
searchIssues(term: "#{term}") {
searchIssues(term: #{Linear::Mutations.graphql_value(term)}) {
nodes {
id
title
+2 -1
View File
@@ -1,7 +1,8 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
class << self
def initialized?
@initialized ||= false
+41
View File
@@ -0,0 +1,41 @@
module Llm::Models
CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze
class << self
def providers = CONFIG['providers']
def models = CONFIG['models']
def features = CONFIG['features']
def feature_keys = CONFIG['features'].keys
def default_model_for(feature)
CONFIG.dig('features', feature.to_s, 'default')
end
def models_for(feature)
CONFIG.dig('features', feature.to_s, 'models') || []
end
def valid_model_for?(feature, model_name)
models_for(feature).include?(model_name.to_s)
end
def feature_config(feature_key)
feature = features[feature_key.to_s]
return nil unless feature
{
models: feature['models'].map do |model_name|
model = models[model_name]
{
id: model_name,
display_name: model['display_name'],
provider: model['provider'],
coming_soon: model['coming_soon'],
credit_multiplier: model['credit_multiplier']
}
end,
default: feature['default']
}
end
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_MODEL = 'gpt-4.1'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
+5 -2
View File
@@ -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}")
+3
View File
@@ -49,4 +49,7 @@ module Redis::RedisKeys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
end
+81
View File
@@ -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
-12
View File
@@ -1,12 +0,0 @@
namespace :companies do
desc 'Backfill companies from existing contact email domains'
task backfill: :environment do
puts 'Starting company backfill migration...'
puts 'This will process all accounts and create companies from contact email domains.'
puts 'The job will run in the background via Sidekiq'
puts ''
Migration::CompanyBackfillJob.perform_later
puts 'Company backfill job has been enqueued.'
puts 'Monitor progress in logs or Sidekiq dashboard.'
end
end
+183
View File
@@ -0,0 +1,183 @@
# Download Report Rake Tasks
#
# Usage:
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:agent
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:inbox
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:label
#
# The task will prompt for:
# - Account ID
# - Start Date (YYYY-MM-DD)
# - End Date (YYYY-MM-DD)
# - Timezone Offset (e.g., 0, 5.5, -5)
# - Business Hours (y/n) - whether to use business hours for time metrics
#
# Output: <account_id>_<type>_<start_date>_<end_date>.csv
require 'csv'
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/ModuleLength
module DownloadReportTasks
def self.prompt(message)
print "#{message}: "
$stdin.gets.chomp
end
def self.collect_params
account_id = prompt('Enter Account ID')
abort 'Error: Account ID is required' if account_id.blank?
account = Account.find_by(id: account_id)
abort "Error: Account with ID '#{account_id}' not found" unless account
start_date = prompt('Enter Start Date (YYYY-MM-DD)')
abort 'Error: Start date is required' if start_date.blank?
end_date = prompt('Enter End Date (YYYY-MM-DD)')
abort 'Error: End date is required' if end_date.blank?
timezone_offset = prompt('Enter Timezone Offset (e.g., 0, 5.5, -5)')
timezone_offset = timezone_offset.blank? ? 0 : timezone_offset.to_f
business_hours = prompt('Use Business Hours? (y/n)')
business_hours = business_hours.downcase == 'y'
begin
tz = ActiveSupport::TimeZone[timezone_offset]
abort "Error: Invalid timezone offset '#{timezone_offset}'" unless tz
since = tz.parse("#{start_date} 00:00:00").to_i.to_s
until_date = tz.parse("#{end_date} 23:59:59").to_i.to_s
rescue StandardError => e
abort "Error parsing dates: #{e.message}"
end
{
account: account,
params: { since: since, until: until_date, timezone_offset: timezone_offset, business_hours: business_hours },
start_date: start_date,
end_date: end_date
}
end
def self.save_csv(filename, headers, rows)
CSV.open(filename, 'w') do |csv|
csv << headers
rows.each { |row| csv << row }
end
puts "Report saved to: #{filename}"
end
def self.format_time(seconds)
return '' if seconds.nil? || seconds.zero?
seconds.round(2)
end
def self.download_agent_report
data = collect_params
account = data[:account]
puts "\nGenerating agent report..."
builder = V2::Reports::AgentSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
users = account.users.index_by(&:id)
headers = %w[id name email conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
user = users[row[:id]]
[
row[:id],
user&.name || 'Unknown',
user&.email || 'Unknown',
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_agent_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
def self.download_inbox_report
data = collect_params
account = data[:account]
puts "\nGenerating inbox report..."
builder = V2::Reports::InboxSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
inboxes = account.inboxes.index_by(&:id)
headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
inbox = inboxes[row[:id]]
[
row[:id],
inbox&.name || 'Unknown',
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_inbox_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
def self.download_label_report
data = collect_params
account = data[:account]
puts "\nGenerating label report..."
builder = V2::Reports::LabelSummaryBuilder.new(account: account, params: data[:params])
report = builder.build
headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
rows = report.map do |row|
[
row[:id],
row[:name],
row[:conversations_count],
row[:resolved_conversations_count],
format_time(row[:avg_resolution_time]),
format_time(row[:avg_first_response_time]),
format_time(row[:avg_reply_time])
]
end
filename = "#{account.id}_label_#{data[:start_date]}_#{data[:end_date]}.csv"
save_csv(filename, headers, rows)
end
end
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/ModuleLength
namespace :download_report do
desc 'Download agent summary report as CSV'
task agent: :environment do
DownloadReportTasks.download_agent_report
end
desc 'Download inbox summary report as CSV'
task inbox: :environment do
DownloadReportTasks.download_inbox_report
end
desc 'Download label summary report as CSV'
task label: :environment do
DownloadReportTasks.download_label_report
end
end
@@ -15,13 +15,13 @@ namespace :chatwoot do
days_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i
# Build a common base relation with identical joins for OR compatibility
service = Internal::RemoveOrphanConversationsService.new(account: account, days: days)
# Preview count using the same query logic
base = account
.conversations
.where('conversations.created_at > ?', days.days.ago)
.where('conversations.last_activity_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox)
# Find conversations whose associated contact or inbox record is missing
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
count = conversations.count
@@ -31,8 +31,8 @@ namespace :chatwoot do
print 'Do you want to delete these conversations? (y/N): '
confirm = $stdin.gets.strip.downcase
if %w[y yes].include?(confirm)
conversations.destroy_all
puts 'Conversations deleted.'
total_deleted = service.perform
puts "#{total_deleted} conversations deleted."
else
puts 'No conversations were deleted.'
end
+268
View File
@@ -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
+183
View File
@@ -0,0 +1,183 @@
# rubocop:disable Metrics/BlockLength
namespace :search do
desc 'Create test messages for advanced search manual testing across multiple inboxes'
task setup_test_data: :environment do
puts '🔍 Setting up test data for advanced search...'
account = Account.first
unless account
puts '❌ No account found. Please create an account first.'
exit 1
end
agents = account.users.to_a
unless agents.any?
puts '❌ No agents found. Please create users first.'
exit 1
end
puts "✅ Using account: #{account.name} (ID: #{account.id})"
puts "✅ Found #{agents.count} agent(s)"
# Create missing inbox types for comprehensive testing
puts "\n📥 Checking and creating inboxes..."
# API inbox
unless account.inboxes.exists?(channel_type: 'Channel::Api')
puts ' Creating API inbox...'
account.inboxes.create!(
name: 'Search Test API',
channel: Channel::Api.create!(account: account)
)
end
# Web Widget inbox
unless account.inboxes.exists?(channel_type: 'Channel::WebWidget')
puts ' Creating WebWidget inbox...'
account.inboxes.create!(
name: 'Search Test WebWidget',
channel: Channel::WebWidget.create!(account: account, website_url: 'https://example.com')
)
end
# Email inbox
unless account.inboxes.exists?(channel_type: 'Channel::Email')
puts ' Creating Email inbox...'
account.inboxes.create!(
name: 'Search Test Email',
channel: Channel::Email.create!(
account: account,
email: 'search-test@example.com',
imap_enabled: false,
smtp_enabled: false
)
)
end
inboxes = account.inboxes.to_a
puts "✅ Using #{inboxes.count} inbox(es):"
inboxes.each { |i| puts " - #{i.name} (ID: #{i.id}, Type: #{i.channel_type})" }
# Create 10 test contacts
contacts = []
10.times do |i|
contacts << account.contacts.find_or_create_by!(
email: "test-customer-#{i}@example.com"
) do |c|
c.name = Faker::Name.name
end
end
puts "✅ Created/found #{contacts.count} test contacts"
target_messages = 50_000
messages_per_conversation = 100
total_conversations = target_messages / messages_per_conversation
puts "\n📝 Creating #{target_messages} messages across #{total_conversations} conversations..."
puts " Distribution: #{inboxes.count} inboxes × #{total_conversations / inboxes.count} conversations each"
start_time = 2.years.ago
end_time = Time.current
time_range = end_time - start_time
created_count = 0
failed_count = 0
conversations_per_inbox = total_conversations / inboxes.count
conversation_statuses = [:open, :resolved]
inboxes.each do |inbox|
conversations_per_inbox.times do
# Pick random contact and agent for this conversation
contact = contacts.sample
agent = agents.sample
# Create or find ContactInbox
contact_inbox = ContactInbox.find_or_create_by!(
contact: contact,
inbox: inbox
) do |ci|
ci.source_id = "test_#{SecureRandom.hex(8)}"
end
# Create conversation
conversation = inbox.conversations.create!(
account: account,
contact: contact,
inbox: inbox,
contact_inbox: contact_inbox,
status: conversation_statuses.sample
)
# Create messages for this conversation (50 incoming, 50 outgoing)
50.times do
random_time = start_time + (rand * time_range)
# Incoming message from contact
begin
Message.create!(
content: Faker::Movie.quote,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
sender: contact,
created_at: random_time,
updated_at: random_time
)
created_count += 1
rescue StandardError => e
failed_count += 1
puts "❌ Failed to create message: #{e.message}" if failed_count <= 5
end
# Outgoing message from agent
begin
Message.create!(
content: Faker::Movie.quote,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :outgoing,
sender: agent,
created_at: random_time + rand(60..600),
updated_at: random_time + rand(60..600)
)
created_count += 1
rescue StandardError => e
failed_count += 1
puts "❌ Failed to create message: #{e.message}" if failed_count <= 5
end
print "\r🔄 Progress: #{created_count}/#{target_messages} messages created..." if (created_count % 500).zero?
end
end
end
puts "\n\n✅ Successfully created #{created_count} messages!"
puts "❌ Failed: #{failed_count}" if failed_count.positive?
puts "\n📊 Summary:"
puts " - Total messages: #{Message.where(account: account).count}"
puts " - Total conversations: #{Conversation.where(account: account).count}"
min_date = Message.where(account: account).minimum(:created_at)&.strftime('%Y-%m-%d')
max_date = Message.where(account: account).maximum(:created_at)&.strftime('%Y-%m-%d')
puts " - Date range: #{min_date} to #{max_date}"
puts "\nBreakdown by inbox:"
inboxes.each do |inbox|
msg_count = Message.where(inbox: inbox).count
conv_count = Conversation.where(inbox: inbox).count
puts " - #{inbox.name} (#{inbox.channel_type}): #{msg_count} messages, #{conv_count} conversations"
end
puts "\nBreakdown by sender type:"
puts " - Incoming (from contacts): #{Message.where(account: account, message_type: :incoming).count}"
puts " - Outgoing (from agents): #{Message.where(account: account, message_type: :outgoing).count}"
puts "\n🔧 Next steps:"
puts ' 1. Ensure OpenSearch is running: mise elasticsearch-start'
puts ' 2. Reindex messages: rails runner "Message.search_index.import Message.all"'
puts " 3. Enable feature: rails runner \"Account.find(#{account.id}).enable_features('advanced_search')\""
puts "\n💡 Then test the search with filters via API or Rails console!"
end
end
# rubocop:enable Metrics/BlockLength
+44 -13
View File
@@ -1,51 +1,78 @@
class Webhooks::Trigger
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
def initialize(url, payload, webhook_type)
def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil)
@url = url
@payload = payload
@webhook_type = webhook_type
@secret = secret
@delivery_id = delivery_id
end
def self.execute(url, payload, webhook_type)
new(url, payload, webhook_type).execute
def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil)
new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute
end
def execute
perform_request
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
raise if @webhook_type == :agent_bot_webhook
handle_failure(e)
rescue StandardError => e
handle_error(e)
Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{e.message}"
handle_failure(e)
end
def handle_failure(error)
handle_error(error)
Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{error.message}"
end
private
def perform_request
body = @payload.to_json
RestClient::Request.execute(
method: :post,
url: @url,
payload: @payload.to_json,
headers: { content_type: :json, accept: :json },
payload: body,
headers: request_headers(body),
timeout: webhook_timeout
)
end
def request_headers(body)
headers = { content_type: :json, accept: :json }
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
if @secret.present?
ts = Time.now.to_i.to_s
headers['X-Chatwoot-Timestamp'] = ts
headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}"
end
headers
end
def handle_error(error)
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message
case @webhook_type
when :agent_bot_webhook
conversation = message.conversation
return unless conversation&.pending?
conversation.open!
create_agent_bot_error_activity(conversation)
update_conversation_status(message)
when :api_inbox_webhook
update_message_status(error)
end
end
def update_conversation_status(message)
conversation = message.conversation
return unless conversation&.pending?
return if conversation&.account&.keep_pending_on_bot_failure
conversation.open!
create_agent_bot_error_activity(conversation)
end
def create_agent_bot_error_activity(conversation)
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
@@ -67,7 +94,11 @@ class Webhooks::Trigger
def message
return if message_id.blank?
@message ||= Message.find_by(id: message_id)
if defined?(@message)
@message
else
@message = Message.find_by(id: message_id)
end
end
def message_id