Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

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

0
lib/assets/.keep Normal file
View File

View File

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

52
lib/chatwoot_app.rb Normal file
View File

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

25
lib/chatwoot_captcha.rb Normal file
View File

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

View File

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

120
lib/chatwoot_hub.rb Normal file
View File

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

View File

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

91
lib/config_loader.rb Normal file
View File

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

15
lib/current.rb Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

52
lib/dyte.rb Normal file
View File

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

14
lib/events/base.rb Normal file
View File

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

60
lib/events/types.rb Normal file
View File

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

19
lib/exception_list.rb Normal file
View File

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

226
lib/filters/filter_keys.yml Normal file
View File

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

57
lib/global_config.rb Normal file
View File

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

View File

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

View File

@@ -0,0 +1,63 @@
class Integrations::BotProcessorService
pattr_initialize [:event_name!, :hook!, :event_data!]
def perform
message = event_data[:message]
return unless should_run_processor?(message)
process_content(message)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception
end
private
def should_run_processor?(message)
return if message.private?
return unless processable_message?(message)
return unless conversation.pending?
true
end
def conversation
message = event_data[:message]
@conversation ||= message.conversation
end
def process_content(message)
content = message_content(message)
response = get_response(conversation.contact_inbox.source_id, content) if content.present?
process_response(message, response) if response.present?
end
def message_content(message)
# TODO: might needs to change this to a way that we fetch the updated value from event data instead
# cause the message.updated event could be that that the message was deleted
return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated'
message.content
end
def processable_message?(message)
# TODO: change from reportable and create a dedicated method for this?
return unless message.reportable?
return if message.outgoing? && !processable_outgoing_message?(message)
true
end
def processable_outgoing_message?(message)
event_name == 'message.updated' && ['input_select'].include?(message.content_type)
end
def process_action(message, action)
case action
when 'handoff'
message.conversation.bot_handoff!
when 'resolve'
message.conversation.resolved!
end
end
end

View File

@@ -0,0 +1,66 @@
class Integrations::Captain::ProcessorService < Integrations::BotProcessorService
pattr_initialize [:event_name!, :hook!, :event_data!]
private
def get_response(_session_id, message_content)
call_captain(message_content)
end
def process_response(message, response)
if response == 'conversation_handoff'
message.conversation.bot_handoff!
else
create_conversation(message, { content: response })
end
end
def create_conversation(message, content_params)
return if content_params.blank?
conversation = message.conversation
conversation.messages.create!(
content_params.merge(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id
}
)
)
end
def call_captain(message_content)
url = "#{GlobalConfigService.load('CAPTAIN_API_URL',
'')}/accounts/#{hook.settings['account_id']}/assistants/#{hook.settings['assistant_id']}/chat"
headers = {
'X-USER-EMAIL' => hook.settings['account_email'],
'X-USER-TOKEN' => hook.settings['access_token'],
'Content-Type' => 'application/json'
}
body = {
message: message_content,
previous_messages: previous_messages
}
response = HTTParty.post(url, headers: headers, body: body.to_json)
response.parsed_response['message']
end
def previous_messages
previous_messages = []
conversation.messages.where(message_type: [:outgoing, :incoming]).where(private: false).offset(1).find_each do |message|
next if message.content_type != 'text'
role = determine_role(message)
previous_messages << { message: message.content, type: role }
end
previous_messages
end
def determine_role(message)
message.message_type == 'incoming' ? 'User' : 'Bot'
end
end

View File

@@ -0,0 +1,101 @@
require 'google/cloud/dialogflow/v2'
class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorService
pattr_initialize [:event_name!, :hook!, :event_data!]
private
def message_content(message)
# TODO: might needs to change this to a way that we fetch the updated value from event data instead
# cause the message.updated event could be that that the message was deleted
return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated'
message.content
end
def get_response(session_id, message_content)
if hook.settings['credentials'].blank?
Rails.logger.warn "Account: #{hook.try(:account_id)} Hook: #{hook.id} credentials are not present." && return
end
configure_dialogflow_client_defaults
detect_intent(session_id, message_content)
rescue Google::Cloud::PermissionDeniedError => e
Rails.logger.warn "DialogFlow Error: (account-#{hook.try(:account_id)}, hook-#{hook.id}) #{e.message}"
hook.prompt_reauthorization!
hook.disable
end
def process_response(message, response)
fulfillment_messages = response.query_result['fulfillment_messages']
fulfillment_messages.each do |fulfillment_message|
content_params = generate_content_params(fulfillment_message)
if content_params['action'].present?
process_action(message, content_params['action'])
else
create_conversation(message, content_params)
end
end
end
def generate_content_params(fulfillment_message)
text_response = fulfillment_message['text'].to_h
content_params = { content: text_response[:text].first } if text_response[:text].present?
content_params ||= fulfillment_message['payload'].to_h
content_params
end
def create_conversation(message, content_params)
return if content_params.blank?
conversation = message.conversation
conversation.messages.create!(
content_params.merge(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id
}
)
)
end
def configure_dialogflow_client_defaults
::Google::Cloud::Dialogflow::V2::Sessions::Client.configure do |config|
config.timeout = 10.0
config.credentials = hook.settings['credentials']
config.endpoint = dialogflow_endpoint
end
end
def normalized_region
region = hook.settings['region'].to_s.strip
(region.presence || 'global')
end
def dialogflow_endpoint
region = normalized_region
return 'dialogflow.googleapis.com' if region == 'global'
"#{region}-dialogflow.googleapis.com"
end
def detect_intent(session_id, message)
client = ::Google::Cloud::Dialogflow::V2::Sessions::Client.new
session = build_session_path(session_id)
query_input = { text: { text: message, language_code: 'en-US' } }
client.detect_intent session: session, query_input: query_input
end
def build_session_path(session_id)
project_id = hook.settings['project_id']
region = normalized_region
if region == 'global'
"projects/#{project_id}/agent/sessions/#{session_id}"
else
"projects/#{project_id}/locations/#{region}/agent/sessions/#{session_id}"
end
end
end

View File

@@ -0,0 +1,54 @@
class Integrations::Dyte::ProcessorService
pattr_initialize [:account!, :conversation!]
def create_a_meeting(agent)
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
response = dyte_client.create_a_meeting(title)
return response if response[:error].present?
meeting = response
message = create_a_dyte_integration_message(meeting, title, agent)
message.push_event_data
end
def add_participant_to_meeting(meeting_id, user)
dyte_client.add_participant_to_meeting(meeting_id, user.id, user.name, avatar_url(user))
end
private
def create_a_dyte_integration_message(meeting, title, agent)
@conversation.messages.create!(
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :outgoing,
content_type: :integrations,
content: title,
content_attributes: {
type: 'dyte',
data: {
meeting_id: meeting['id']
}
},
sender: agent
}
)
end
def avatar_url(user)
return user.avatar_url if user.avatar_url.present?
"#{ENV.fetch('FRONTEND_URL', nil)}/integrations/slack/user.png"
end
def dyte_hook
@dyte_hook ||= account.hooks.find_by!(app_id: 'dyte')
end
def dyte_client
credentials = dyte_hook.settings
@dyte_client ||= Dyte.new(credentials['organization_id'], credentials['api_key'])
end
end

View File

@@ -0,0 +1,37 @@
# frozen_string_literal: true
class Integrations::Facebook::DeliveryStatus
pattr_initialize [:params!]
def perform
return if facebook_channel.blank?
return unless conversation
process_delivery_status if params.delivery_watermark
process_read_status if params.read_watermark
end
private
def process_delivery_status
timestamp = Time.zone.at(params.delivery_watermark.to_i).to_datetime.utc
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :delivered)
end
def process_read_status
timestamp = Time.zone.at(params.read_watermark.to_i).to_datetime.utc
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :read)
end
def contact
::ContactInbox.find_by(source_id: params.sender_id)&.contact
end
def conversation
@conversation ||= ::Conversation.find_by(contact_id: contact.id) if contact.present?
end
def facebook_channel
@facebook_channel ||= Channel::FacebookPage.find_by(page_id: params.recipient_id)
end
end

View File

@@ -0,0 +1,44 @@
# frozen_string_literal: true
class Integrations::Facebook::MessageCreator
attr_reader :response
def initialize(response)
@response = response
end
def perform
# begin
if agent_message_via_echo?
create_agent_message
else
create_contact_message
end
# rescue => e
# ChatwootExceptionTracker.new(e).capture_exception
# end
end
private
def agent_message_via_echo?
# TODO : check and remove send_from_chatwoot_app if not working
response.echo? && !response.sent_from_chatwoot_app?
# this means that it is an agent message from page, but not sent from chatwoot.
# User can send from fb page directly on mobile / web messenger, so this case should be handled as agent message
end
def create_agent_message
Channel::FacebookPage.where(page_id: response.sender_id).each do |page|
mb = Messages::Facebook::MessageBuilder.new(response, page.inbox, outgoing_echo: true)
mb.perform
end
end
def create_contact_message
Channel::FacebookPage.where(page_id: response.recipient_id).each do |page|
mb = Messages::Facebook::MessageBuilder.new(response, page.inbox)
mb.perform
end
end
end

View File

@@ -0,0 +1,89 @@
# frozen_string_literal: true
class Integrations::Facebook::MessageParser
def initialize(response_json)
@response = JSON.parse(response_json)
@messaging = @response['messaging'] || @response['standby']
end
def sender_id
@messaging.dig('sender', 'id')
end
def recipient_id
@messaging.dig('recipient', 'id')
end
def time_stamp
@messaging['timestamp']
end
def content
@messaging.dig('message', 'text')
end
def sequence
@messaging.dig('message', 'seq')
end
def attachments
@messaging.dig('message', 'attachments')
end
def identifier
@messaging.dig('message', 'mid')
end
def delivery
@messaging['delivery']
end
def read
@messaging['read']
end
def read_watermark
read&.dig('watermark')
end
def delivery_watermark
delivery&.dig('watermark')
end
def echo?
@messaging.dig('message', 'is_echo')
end
# TODO : i don't think the payload contains app_id. if not remove
def app_id
@messaging.dig('message', 'app_id')
end
# TODO : does this work ?
def sent_from_chatwoot_app?
app_id && app_id == GlobalConfigService.load('FB_APP_ID', '').to_i
end
def in_reply_to_external_id
@messaging.dig('message', 'reply_to', 'mid')
end
end
# Sample Response
# {
# "sender":{
# "id":"USER_ID"
# },
# "recipient":{
# "id":"PAGE_ID"
# },
# "timestamp":1458692752478,
# "message":{
# "mid":"mid.1457764197618:41d102a3e1ae206a38",
# "seq":73,
# "text":"hello, world!",
# "quick_reply": {
# "payload": "DEVELOPER_DEFINED_PAYLOAD"
# }
# }
# }

View File

@@ -0,0 +1,41 @@
require 'google/cloud/translate/v3'
class Integrations::GoogleTranslate::DetectLanguageService
pattr_initialize [:hook!, :message!]
def perform
return unless valid_message?
return if conversation.additional_attributes['conversation_language'].present?
text = message.content[0...1500]
response = client.detect_language(
content: text,
parent: "projects/#{hook.settings['project_id']}"
)
update_conversation(response)
end
private
def valid_message?
message.incoming? && message.content.present?
end
def conversation
@conversation ||= message.conversation
end
def update_conversation(response)
return if response&.languages.blank?
conversation_language = response.languages.first.language_code
additional_attributes = conversation.additional_attributes.merge({ conversation_language: conversation_language })
conversation.update!(additional_attributes: additional_attributes)
end
def client
@client ||= ::Google::Cloud::Translate::V3::TranslationService::Client.new do |config|
config.credentials = hook.settings['credentials']
end
end
end

View File

@@ -0,0 +1,76 @@
require 'google/cloud/translate/v3'
class Integrations::GoogleTranslate::ProcessorService
pattr_initialize [:message!, :target_language!]
def perform
return if hook.blank?
content = translation_content
return if content.blank?
response = client.translate_text(
contents: [content],
target_language_code: bcp47_language_code,
parent: "projects/#{hook.settings['project_id']}",
mime_type: mime_type
)
return if response.translations.first.blank?
response.translations.first.translated_text
end
private
def bcp47_language_code
target_language.tr('_', '-')
end
def email_channel?
message&.inbox&.email?
end
def email_content
@email_content ||= {
html: message.content_attributes.dig('email', 'html_content', 'full'),
text: message.content_attributes.dig('email', 'text_content', 'full'),
content_type: message.content_attributes.dig('email', 'content_type')
}
end
def html_content_available?
email_content[:html].present?
end
def plain_text_content_available?
email_content[:content_type]&.include?('text/plain') &&
email_content[:text].present?
end
def translation_content
return message.content unless email_channel?
return email_content[:html] if html_content_available?
return email_content[:text] if plain_text_content_available?
message.content
end
def mime_type
if email_channel? && html_content_available?
'text/html'
else
'text/plain'
end
end
def hook
@hook ||= message.account.hooks.find_by(app_id: 'google_translate')
end
def client
@client ||= ::Google::Cloud::Translate::V3::TranslationService::Client.new do |config|
config.credentials = hook.settings['credentials']
end
end
end

View File

@@ -0,0 +1,82 @@
class Integrations::Linear::ProcessorService
pattr_initialize [:account!]
def teams
response = linear_client.teams
return { error: response[:error] } if response[:error]
{ data: response['teams']['nodes'].map(&:as_json) }
end
def team_entities(team_id)
response = linear_client.team_entities(team_id)
return response if response[:error]
{
data: {
users: response['users']['nodes'].map(&:as_json),
projects: response['projects']['nodes'].map(&:as_json),
states: response['workflowStates']['nodes'].map(&:as_json),
labels: response['issueLabels']['nodes'].map(&:as_json)
}
}
end
def create_issue(params, user = nil)
response = linear_client.create_issue(params, user)
return response if response[:error]
{
data: { id: response['issueCreate']['issue']['id'],
title: response['issueCreate']['issue']['title'],
identifier: response['issueCreate']['issue']['identifier'] }
}
end
def link_issue(link, issue_id, title, user = nil)
response = linear_client.link_issue(link, issue_id, title, user)
return response if response[:error]
{
data: {
id: issue_id,
link: link,
link_id: response.with_indifferent_access[:attachmentLinkURL][:attachment][:id]
}
}
end
def unlink_issue(link_id)
response = linear_client.unlink_issue(link_id)
return response if response[:error]
{
data: { link_id: link_id }
}
end
def search_issue(term)
response = linear_client.search_issue(term)
return response if response[:error]
{ data: response['searchIssues']['nodes'].map(&:as_json) }
end
def linked_issues(url)
response = linear_client.linked_issues(url)
return response if response[:error]
{ data: response['attachmentsForURL']['nodes'].map(&:as_json) }
end
private
def linear_hook
@linear_hook ||= account.hooks.find_by!(app_id: 'linear')
end
def linear_client
@linear_client ||= Linear.new(linear_hook.access_token)
end
end

View File

@@ -0,0 +1,169 @@
class Integrations::LlmBaseService
include Integrations::LlmInstrumentation
# gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters
# sticking with 120000 to be safe
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
ALLOWED_EVENT_NAMES = %w[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?
deserialize_cached_value(Redis::Alfred.get(cache_key))
end
def deserialize_cached_value(value)
return nil if value.blank?
JSON.parse(value, symbolize_names: true)
rescue JSON::ParserError
# If json parse failed, returning the value as is will fail too
# since we access the keys as symbols down the line
# So it's best to return nil
nil
end
def save_to_cache(response)
return nil unless event_is_cacheable?
# Serialize to JSON
# This makes parsing easy when response is a hash
Redis::Alfred.setex(cache_key, response.to_json)
end
def conversation
@conversation ||= hook.account.conversations.find_by(display_id: event['data']['conversation_display_id'])
end
def valid_event_name?
# self.class::ALLOWED_EVENT_NAMES is way to access ALLOWED_EVENT_NAMES defined in the class hierarchy of the current object.
# This ensures that if ALLOWED_EVENT_NAMES is updated elsewhere in it's ancestors, we access the latest value.
self.class::ALLOWED_EVENT_NAMES.include?(event_name)
end
def event_is_cacheable?
# self.class::CACHEABLE_EVENTS is way to access CACHEABLE_EVENTS defined in the class hierarchy of the current object.
# This ensures that if CACHEABLE_EVENTS is updated elsewhere in it's ancestors, we access the latest value.
self.class::CACHEABLE_EVENTS.include?(event_name)
end
def api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1"
end
def make_api_call(body)
parsed_body = JSON.parse(body)
instrumentation_params = build_instrumentation_params(parsed_body)
instrument_llm_call(instrumentation_params) do
execute_ruby_llm_request(parsed_body)
end
end
def execute_ruby_llm_request(parsed_body)
messages = parsed_body['messages']
model = parsed_body['model']
Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context|
chat = context.chat(model: model)
setup_chat_with_messages(chat, messages)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
build_error_response_from_exception(e, messages)
end
def setup_chat_with_messages(chat, messages)
apply_system_instructions(chat, messages)
response = send_conversation_messages(chat, messages)
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if response.nil?
build_ruby_llm_response(response, messages)
end
def apply_system_instructions(chat, messages)
system_msg = messages.find { |m| m['role'] == 'system' }
chat.with_instructions(system_msg['content']) if system_msg
end
def send_conversation_messages(chat, messages)
conversation_messages = messages.reject { |m| m['role'] == 'system' }
return nil if conversation_messages.empty?
return chat.ask(conversation_messages.first['content']) if conversation_messages.length == 1
add_conversation_history(chat, conversation_messages[0...-1])
chat.ask(conversation_messages.last['content'])
end
def add_conversation_history(chat, messages)
messages.each do |msg|
chat.add_message(role: msg['role'].to_sym, content: msg['content'])
end
end
def build_ruby_llm_response(response, messages)
{
message: response.content,
usage: {
'prompt_tokens' => response.input_tokens,
'completion_tokens' => response.output_tokens,
'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
},
request_messages: messages
}
end
def build_instrumentation_params(parsed_body)
{
span_name: "llm.#{event_name}",
account_id: hook.account_id,
conversation_id: conversation&.display_id,
feature_name: event_name,
model: parsed_body['model'],
messages: parsed_body['messages'],
temperature: parsed_body['temperature']
}
end
def build_error_response_from_exception(error, messages)
{ error: error.message, request_messages: messages }
end
end

View File

@@ -0,0 +1,121 @@
# frozen_string_literal: true
require 'opentelemetry_config'
module Integrations::LlmInstrumentation
include Integrations::LlmInstrumentationConstants
include Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationSpans
def instrument_llm_call(params)
return yield unless ChatwootApp.otel_enabled?
result = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
setup_span_attributes(span, params)
result = yield
executed = true
record_completion(span, result)
result
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
executed ? result : yield
end
def instrument_agent_session(params)
return yield unless ChatwootApp.otel_enabled?
result = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
set_metadata_attributes(span, params)
# By default, the input and output of a trace are set from the root observation
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
result = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
result
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
executed ? result : yield
end
def instrument_tool_call(tool_name, arguments)
# There is no error handling because tools can fail and LLMs should be
# aware of those failures and factor them into their response.
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
result
end
end
def instrument_embedding_call(params)
return yield unless ChatwootApp.otel_enabled?
instrument_with_span(params[:span_name] || 'llm.embedding', params) do |span, track_result|
set_embedding_span_attributes(span, params)
result = yield
track_result.call(result)
set_embedding_result_attributes(span, result)
result
end
end
def instrument_audio_transcription(params)
return yield unless ChatwootApp.otel_enabled?
instrument_with_span(params[:span_name] || 'llm.audio.transcription', params) do |span, track_result|
set_audio_transcription_span_attributes(span, params)
result = yield
track_result.call(result)
set_transcription_result_attributes(span, result)
result
end
end
def instrument_moderation_call(params)
return yield unless ChatwootApp.otel_enabled?
instrument_with_span(params[:span_name] || 'llm.moderation', params) do |span, track_result|
set_moderation_span_attributes(span, params)
result = yield
track_result.call(result)
set_moderation_result_attributes(span, result)
result
end
end
def instrument_with_span(span_name, params, &)
result = nil
executed = false
tracer.in_span(span_name) do |span|
track_result = lambda do |r|
executed = true
result = r
end
yield(span, track_result)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
raise unless executed
result
end
private
def resolve_account(params)
return params[:account] if params[:account].is_a?(Account)
return Account.find_by(id: params[:account_id]) if params[:account_id].present?
nil
end
end

View File

@@ -0,0 +1,88 @@
# frozen_string_literal: true
module Integrations::LlmInstrumentationCompletionHelpers
include Integrations::LlmInstrumentationConstants
private
def set_embedding_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, determine_provider(params[:model]))
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
set_common_span_metadata(span, params)
end
def set_audio_transcription_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
set_common_span_metadata(span, params)
end
def set_moderation_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
set_common_span_metadata(span, params)
end
def set_common_span_metadata(span, params)
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
end
def set_embedding_result_attributes(span, result)
span.set_attribute('embedding.dimensions', result&.length || 0) if result.is_a?(Array)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, "[#{result&.length || 0} dimensions]")
end
def set_transcription_result_attributes(span, result)
transcribed_text = result.respond_to?(:text) ? result.text : result.to_s
span.set_attribute('transcription.length', transcribed_text&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, transcribed_text.to_s)
end
def set_moderation_result_attributes(span, result)
span.set_attribute('moderation.flagged', result.flagged?) if result.respond_to?(:flagged?)
span.set_attribute('moderation.categories', result.flagged_categories.to_json) if result.respond_to?(:flagged_categories)
output = {
flagged: result.respond_to?(:flagged?) ? result.flagged? : nil,
categories: result.respond_to?(:flagged_categories) ? result.flagged_categories : []
}
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output.to_json)
end
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
end
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
end
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
end

View File

@@ -0,0 +1,31 @@
# 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_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
end

View File

@@ -0,0 +1,65 @@
# frozen_string_literal: true
module Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationConstants
include Integrations::LlmInstrumentationCompletionHelpers
def determine_provider(model_name)
return 'openai' if model_name.blank?
model = model_name.to_s.downcase
LlmConstants::PROVIDER_PREFIXES.each do |provider, prefixes|
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
end
'openai'
end
private
def setup_span_attributes(span, params)
set_request_attributes(span, params)
set_prompt_messages(span, params[:messages])
set_metadata_attributes(span, params)
end
def record_completion(span, result)
if result.respond_to?(:content)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
elsif result.is_a?(Hash)
set_completion_attributes(span, result)
end
end
def set_request_attributes(span, params)
provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
def set_prompt_messages(span, messages)
messages.each_with_index do |msg, idx|
role = msg[:role] || msg['role']
content = msg[:content] || msg['content']
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
end
end
def set_metadata_attributes(span, params)
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
return unless params[:metadata].is_a?(Hash)
params[:metadata].each do |key, value|
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
end
end
end

View File

@@ -0,0 +1,91 @@
# frozen_string_literal: true
require 'opentelemetry_config'
module Integrations::LlmInstrumentationSpans
include Integrations::LlmInstrumentationConstants
def tracer
@tracer ||= OpentelemetryConfig.tracer
end
def start_llm_turn_span(params)
return unless ChatwootApp.otel_enabled?
span = tracer.start_span(params[:span_name])
set_llm_turn_request_attributes(span, params)
set_llm_turn_prompt_attributes(span, params[:messages]) if params[:messages]
@pending_llm_turn_spans ||= []
@pending_llm_turn_spans.push(span)
rescue StandardError => e
Rails.logger.warn "Failed to start LLM turn span: #{e.message}"
end
def end_llm_turn_span(message)
return unless ChatwootApp.otel_enabled?
span = @pending_llm_turn_spans&.pop
return unless span
set_llm_turn_response_attributes(span, message) if message
span.finish
rescue StandardError => e
Rails.logger.warn "Failed to end LLM turn span: #{e.message}"
end
def start_tool_span(tool_call)
return unless ChatwootApp.otel_enabled?
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@pending_tool_spans ||= []
@pending_tool_spans.push(span)
rescue StandardError => e
Rails.logger.warn "Failed to start tool span: #{e.message}"
end
def end_tool_span(result)
return unless ChatwootApp.otel_enabled?
span = @pending_tool_spans&.pop
return unless span
output = result.is_a?(String) ? result : result.to_json
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output)
span.finish
rescue StandardError => e
Rails.logger.warn "Failed to end tool span: #{e.message}"
end
private
def set_llm_turn_request_attributes(span, params)
provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) if params[:model]
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
def set_llm_turn_prompt_attributes(span, messages)
messages.each_with_index do |msg, idx|
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), msg[:role])
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), msg[:content])
end
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, messages.to_json)
end
def set_llm_turn_response_attributes(span, message)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, message.role.to_s) if message.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.content.to_s) if message.respond_to?(:content)
set_llm_turn_usage_attributes(span, message)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
end
def set_llm_turn_usage_attributes(span, message)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) if message.respond_to?(:input_tokens) && message.input_tokens
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) && message.output_tokens
end
end

View File

@@ -0,0 +1 @@
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.

View File

@@ -0,0 +1 @@
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.

View File

@@ -0,0 +1,138 @@
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')

View File

@@ -0,0 +1,70 @@
class Integrations::Slack::ChannelBuilder
attr_reader :params, :channel
def initialize(params)
@params = params
end
def fetch_channels
channels
end
def update(reference_id)
update_reference_id(reference_id)
end
private
def hook
@hook ||= params[:hook]
end
def slack_client
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
end
def channels
# Split channel fetching into separate API calls to avoid rate limiting issues.
# Slack's API handles single-type requests (public OR private) much more efficiently
# than mixed-type requests (public AND private). This approach eliminates rate limits
# that occur when requesting both channel types simultaneously.
channel_list = []
# Step 1: Fetch all private channels in one call (expect very few)
private_channels = fetch_channels_by_type('private_channel')
channel_list.concat(private_channels)
# Step 2: Fetch public channels with pagination
public_channels = fetch_channels_by_type('public_channel')
channel_list.concat(public_channels)
channel_list
end
def fetch_channels_by_type(channel_type, limit: 1000)
conversations_list = slack_client.conversations_list(types: channel_type, exclude_archived: true, limit: limit)
channel_list = conversations_list.channels
while conversations_list.response_metadata.next_cursor.present?
conversations_list = slack_client.conversations_list(
cursor: conversations_list.response_metadata.next_cursor,
types: channel_type,
exclude_archived: true,
limit: limit
)
channel_list.concat(conversations_list.channels)
end
channel_list
end
def find_channel(reference_id)
channels.find { |channel| channel['id'] == reference_id }
end
def update_reference_id(reference_id)
channel = find_channel(reference_id)
return if channel.blank?
slack_client.conversations_join(channel: channel[:id]) if channel[:is_private] == false
@hook.update!(reference_id: channel[:id], settings: { channel_name: channel[:name] }, status: 'enabled')
@hook
end
end

View File

@@ -0,0 +1,42 @@
class Integrations::Slack::HookBuilder
attr_reader :params
def initialize(params)
@params = params
end
def perform
token = fetch_access_token
hook = account.hooks.new(
access_token: token,
status: 'disabled',
inbox_id: params[:inbox_id],
app_id: 'slack'
)
hook.save!
hook
end
private
def account
params[:account]
end
def hook_type
params[:inbox_id] ? 'inbox' : 'account'
end
def fetch_access_token
client = Slack::Web::Client.new
slack_access = client.oauth_v2_access(
client_id: GlobalConfigService.load('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
client_secret: GlobalConfigService.load('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
code: params[:code],
redirect_uri: Integrations::App.slack_integration_url
)
slack_access['access_token']
end
end

View File

@@ -0,0 +1,103 @@
class Integrations::Slack::IncomingMessageBuilder
include Integrations::Slack::SlackMessageHelper
attr_reader :params
SUPPORTED_EVENT_TYPES = %w[event_callback url_verification].freeze
SUPPORTED_EVENTS = %w[message link_shared].freeze
SUPPORTED_MESSAGE_TYPES = %w[rich_text].freeze
def initialize(params)
@params = params
end
def perform
return unless valid_event?
if hook_verification?
verify_hook
elsif process_message_payload?
process_message_payload
elsif link_shared?
SlackUnfurlJob.perform_later(params)
end
end
private
def valid_event?
supported_event_type? && supported_event? && should_process_event?
end
def supported_event_type?
SUPPORTED_EVENT_TYPES.include?(params[:type])
end
# Discard all the subtype of a message event
# We are only considering the actual message sent by a Slack user
# Any reactions or messages sent by the bot will be ignored.
# https://api.slack.com/events/message#subtypes
def should_process_event?
return true if params[:type] != 'event_callback'
params[:event][:user].present? && valid_event_subtype?
end
def valid_event_subtype?
params[:event][:subtype].blank? || params[:event][:subtype] == 'file_share'
end
def supported_event?
hook_verification? || SUPPORTED_EVENTS.include?(params[:event][:type])
end
def supported_message?
if message.present?
SUPPORTED_MESSAGE_TYPES.include?(message[:type]) && !attached_file_message?
else
params[:event][:files].present? && !attached_file_message?
end
end
def hook_verification?
params[:type] == 'url_verification'
end
def thread_timestamp_available?
params[:event][:thread_ts].present?
end
def process_message_payload?
thread_timestamp_available? && supported_message? && integration_hook
end
def link_shared?
params[:event][:type] == 'link_shared'
end
def message
params[:event][:blocks]&.first
end
def verify_hook
{
challenge: params[:challenge]
}
end
def integration_hook
@integration_hook ||= Integrations::Hook.find_by(reference_id: params[:event][:channel])
end
def slack_client
@slack_client ||= Slack::Web::Client.new(token: @integration_hook.access_token)
end
# Ignoring the changes added here https://github.com/chatwoot/chatwoot/blob/5b5a6d89c0cf7f3148a1439d6fcd847784a79b94/lib/integrations/slack/send_on_slack_service.rb#L69
# This make sure 'Attached File!' comment is not visible on CW dashboard.
# This is showing because of https://github.com/chatwoot/chatwoot/pull/4494/commits/07a1c0da1e522d76e37b5f0cecdb4613389ab9b6 change.
# As now we consider the postback message with event[:files]
def attached_file_message?
params[:event][:text] == 'Attached File!'
end
end

View File

@@ -0,0 +1,59 @@
class Integrations::Slack::LinkUnfurlFormatter
pattr_initialize [:url!, :user_info!, :inbox_name!, :inbox_type!]
def perform
return {} if url.blank?
{
url => {
'blocks' => preivew_blocks(user_info) +
open_conversation_button(url)
}
}
end
private
def preivew_blocks(user_info)
[
{
'type' => 'section',
'fields' => [
preview_field(I18n.t('slack_unfurl.fields.name'), user_info[:user_name]),
preview_field(I18n.t('slack_unfurl.fields.email'), user_info[:email]),
preview_field(I18n.t('slack_unfurl.fields.phone_number'), user_info[:phone_number]),
preview_field(I18n.t('slack_unfurl.fields.company_name'), user_info[:company_name]),
preview_field(I18n.t('slack_unfurl.fields.inbox_name'), inbox_name),
preview_field(I18n.t('slack_unfurl.fields.inbox_type'), inbox_type)
]
}
]
end
def preview_field(label, value)
{
'type' => 'mrkdwn',
'text' => "*#{label}:*\n#{value}"
}
end
def open_conversation_button(url)
[
{
'type' => 'actions',
'elements' => [
{
'type' => 'button',
'text' => {
'type' => 'plain_text',
'text' => I18n.t('slack_unfurl.button'),
'emoji' => true
},
'url' => url,
'action_id' => 'button-action'
}
]
}
]
end
end

View File

@@ -0,0 +1,214 @@
class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
include RegexHelper
pattr_initialize [:message!, :hook!]
def perform
# overriding the base class logic since the validations are different in this case.
# FIXME: for now we will only send messages from widget to slack
return unless valid_channel_for_slack?
# we don't want message loop in slack
return if message.external_source_id_slack.present?
# we don't want to start slack thread from agent conversation as of now
return if invalid_message?
perform_reply
end
def link_unfurl(event)
slack_client.chat_unfurl(
event
)
# You may wonder why we're not requesting reauthorization and disabling hooks when scope errors occur.
# Since link unfurling is just a nice-to-have feature that doesn't affect core functionality, we will silently ignore these errors.
rescue Slack::Web::Api::Errors::MissingScope => e
Rails.logger.warn "Slack: Missing scope error: #{e.message}"
end
private
def valid_channel_for_slack?
# slack wouldn't be an ideal interface to reply to tweets, hence disabling that case
return false if channel.is_a?(Channel::TwitterProfile) && conversation.additional_attributes['type'] == 'tweet'
true
end
def invalid_message?
(message.outgoing? || message.template?) && conversation.identifier.blank?
end
def perform_reply
send_message
return unless @slack_message
update_reference_id
update_external_source_id_slack
end
def message_content
private_indicator = message.private? ? 'private: ' : ''
sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content)
if conversation.identifier.present?
"#{private_indicator}#{sanitized_content}"
else
"#{formatted_inbox_name}#{formatted_conversation_link}#{email_subject_line}\n#{sanitized_content}"
end
end
def format_message_content
message.message_type == 'activity' ? "_#{message_text}_" : message_text
end
def message_text
content = message.processed_message_content || message.content
if content.present?
content.gsub(MENTION_REGEX, '\1')
else
content
end
end
def formatted_inbox_name
"\n*Inbox:* #{message.inbox.name} (#{message.inbox.inbox_type})\n"
end
def formatted_conversation_link
"#{link_to_conversation} to view the conversation.\n"
end
def email_subject_line
return '' unless message.inbox.email?
email_payload = message.content_attributes['email']
return "*Subject:* #{email_payload['subject']}\n\n" if email_payload.present? && email_payload['subject'].present?
''
end
def avatar_url(sender)
sender_type = sender_type(sender).downcase
blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil
generate_url(sender_type, blob_key)
end
def generate_url(sender_type, blob_key)
base_url = ENV.fetch('FRONTEND_URL', nil)
"#{base_url}/slack_uploads?blob_key=#{blob_key}&sender_type=#{sender_type}"
end
def send_message
post_message if message_content.present?
upload_files if message.attachments.any?
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
Rails.logger.error e
hook.prompt_reauthorization!
hook.disable
end
def post_message
@slack_message = slack_client.chat_postMessage(
channel: hook.reference_id,
text: message_content,
username: sender_name(message.sender),
thread_ts: conversation.identifier,
icon_url: avatar_url(message.sender),
unfurl_links: conversation.identifier.present?
)
end
def upload_files
files = build_files_array
return if files.empty?
begin
result = slack_client.files_upload_v2(
files: files,
initial_comment: 'Attached File!',
thread_ts: conversation.identifier,
channel_id: hook.reference_id
)
Rails.logger.info "slack_upload_result: #{result}"
rescue Slack::Web::Api::Errors::SlackError => e
Rails.logger.error "Failed to upload files: #{e.message}"
ensure
files.each { |file| file[:content]&.clear }
end
end
def build_files_array
message.attachments.filter_map do |attachment|
next unless attachment.with_attached_file?
build_file_payload(attachment)
end
end
def build_file_payload(attachment)
content = download_attachment_content(attachment)
return if content.blank?
{
filename: attachment.file.filename.to_s,
content: content,
title: attachment.file.filename.to_s
}
end
def download_attachment_content(attachment)
buffer = +''
attachment.file.blob.open do |file|
while (chunk = file.read(64.kilobytes))
buffer << chunk
end
end
buffer
end
def sender_name(sender)
sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender)
end
def sender_type(sender)
if sender.instance_of?(Contact)
'Contact'
elsif sender.instance_of?(User)
'Agent'
elsif message.message_type == 'activity' && sender.nil?
'System'
else
'Bot'
end
end
def update_reference_id
return unless should_update_reference_id?
conversation.update!(identifier: @slack_message['ts'])
end
def update_external_source_id_slack
return unless @slack_message['message']
message.update!(external_source_id_slack: "cw-origin-#{@slack_message['message']['ts']}")
end
def slack_client
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
end
def link_to_conversation
"<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{conversation.account_id}/conversations/#{conversation.display_id}|Click here>"
end
# Determines whether the conversation identifier should be updated with the ts value.
# The identifier should be updated in the following cases:
# - If the conversation identifier is blank, it means a new conversation is being created.
# - If the thread_ts is blank, it means that the conversation was previously connected in a different channel.
def should_update_reference_id?
conversation.identifier.blank? || @slack_message['message']['thread_ts'].blank?
end
end

View File

@@ -0,0 +1,84 @@
class Integrations::Slack::SlackLinkUnfurlService
pattr_initialize [:params!, :integration_hook!]
def perform
event_links = params.dig(:event, :links)
return unless event_links
event_links.each do |link_info|
url = link_info[:url]
# Unfurl only if the account id is same as the integration hook account id
unfurl_link(url) if url && valid_account?(url)
end
end
def unfurl_link(url)
conversation = conversation_from_url(url)
return unless conversation
send_unfurls(url, conversation)
end
private
def contact_attributes(conversation)
contact = conversation.contact
{
user_name: contact.name.presence || '---',
email: contact.email.presence || '---',
phone_number: contact.phone_number.presence || '---',
company_name: contact.additional_attributes&.dig('company_name').presence || '---'
}
end
def generate_unfurls(url, user_info, inbox)
Integrations::Slack::LinkUnfurlFormatter.new(
url: url,
user_info: user_info,
inbox_name: inbox.name,
inbox_type: inbox.channel.name
).perform
end
def send_unfurls(url, conversation)
user_info = contact_attributes(conversation)
unfurls = generate_unfurls(url, user_info, conversation.inbox)
unfurl_params = {
unfurl_id: params.dig(:event, :unfurl_id),
source: params.dig(:event, :source),
unfurls: JSON.generate(unfurls)
}
slack_service = Integrations::Slack::SendOnSlackService.new(
message: nil,
hook: integration_hook
)
slack_service.link_unfurl(unfurl_params)
end
def conversation_from_url(url)
conversation_id = extract_conversation_id(url)
find_conversation_by_id(conversation_id) if conversation_id
end
def find_conversation_by_id(conversation_id)
Conversation.find_by(display_id: conversation_id, account_id: integration_hook.account_id)
end
def valid_account?(url)
account_id = extract_account_id(url)
account_id == integration_hook.account_id.to_s
end
def extract_account_id(url)
account_id_regex = %r{/accounts/(\d+)}
match_data = url.match(account_id_regex)
match_data[1] if match_data
end
def extract_conversation_id(url)
conversation_id_regex = %r{/conversations/(\d+)}
match_data = url.match(conversation_id_regex)
match_data[1] if match_data
end
end

View File

@@ -0,0 +1,92 @@
module Integrations::Slack::SlackMessageHelper
def process_message_payload
return unless conversation
handle_conversation
success_response
rescue Slack::Web::Api::Errors::MissingScope => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
disable_and_reauthorize
end
def handle_conversation
create_message unless message_exists?
end
def success_response
{ status: 'success' }
end
def disable_and_reauthorize
integration_hook.prompt_reauthorization!
integration_hook.disable
end
def message_exists?
conversation.messages.exists?(external_source_ids: { slack: params[:event][:ts] })
end
def create_message
@message = conversation.messages.build(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
external_source_id_slack: params[:event][:ts],
private: private_note?,
sender: sender
)
process_attachments(params[:event][:files]) if attachments_present?
@message.save!
end
def attachments_present?
params[:event][:files].present?
end
def process_attachments(attachments)
attachments.each do |attachment|
tempfile = Down::NetHttp.download(attachment[:url_private], headers: { 'Authorization' => "Bearer #{integration_hook.access_token}" })
attachment_params = {
file_type: file_type(attachment),
account_id: @message.account_id,
external_url: attachment[:url_private],
file: {
io: tempfile,
filename: tempfile.original_filename,
content_type: tempfile.content_type
}
}
attachment_obj = @message.attachments.new(attachment_params)
attachment_obj.file.content_type = attachment[:mimetype]
end
end
def file_type(attachment)
return if attachment[:mimetype] == 'text/plain'
case attachment[:filetype]
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
:image
when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
:video
else
:file
end
end
def conversation
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
end
def sender
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
conversation.account.users.from_email(user_email)
end
def private_note?
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
end
end

17
lib/limits.rb Normal file
View File

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

158
lib/linear.rb Normal file
View File

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

69
lib/linear/mutations.rb Normal file
View File

@@ -0,0 +1,69 @@
module Linear::Mutations
def self.graphql_value(value)
case value
when String
# Strings must be enclosed in double quotes
"\"#{value.gsub("\n", '\\n')}\""
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
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: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
success
attachment {
id
}
}
}
GRAPHQL
end
def self.unlink_issue(link_id)
<<~GRAPHQL
mutation {
attachmentDelete(id: "#{link_id}") {
success
}
}
GRAPHQL
end
end

104
lib/linear/queries.rb Normal file
View File

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

48
lib/llm/config.rb Normal file
View File

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

41
lib/llm/models.rb Normal file
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

17
lib/llm_constants.rb Normal file
View File

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

View File

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

View File

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

View File

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

143
lib/redis/alfred.rb Normal file
View File

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

49
lib/redis/config.rb Normal file
View File

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

63
lib/redis/lock_manager.rb Normal file
View File

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

52
lib/redis/redis_keys.rb Normal file
View File

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

19
lib/regex_helper.rb Normal file
View File

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

View File

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

105
lib/seeders/inbox_seeder.rb Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

480
lib/seeders/seed_data.yml Normal file
View File

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

0
lib/tasks/.keep Normal file
View File

100
lib/tasks/apply_sla.rake Normal file
View File

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

View File

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

View File

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

13
lib/tasks/build.rake Normal file
View File

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

View File

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

235
lib/tasks/captain_chat.rake Normal file
View File

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

12
lib/tasks/companies.rake Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7
lib/tasks/ip_lookup.rake Normal file
View File

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

65
lib/tasks/mfa.rake Normal file
View File

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

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
# Run with:
# bundle exec rake chatwoot:ops:cleanup_orphan_conversations
namespace :chatwoot do
namespace :ops do
desc 'Identify and delete conversations without a valid contact or inbox in a timeframe'
task cleanup_orphan_conversations: :environment do
print 'Enter Account ID: '
account_id = $stdin.gets.to_i
account = Account.find(account_id)
print 'Enter timeframe in days (default: 7): '
days_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i
# Build a common base relation with identical joins for OR compatibility
base = account
.conversations
.where('conversations.created_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
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)
conversations.destroy_all
puts 'Conversations deleted.'
else
puts 'No conversations were deleted.'
end
end
end
end
end

View File

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

View File

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

View File

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

156
lib/tasks/swagger.rake Normal file
View File

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

18
lib/test_data.rb Normal file
View File

@@ -0,0 +1,18 @@
module TestData
def self.generate
Orchestrator.call
end
def self.cleanup
CleanupService.call
end
end
require_relative 'test_data/constants'
require_relative 'test_data/database_optimizer'
require_relative 'test_data/cleanup_service'
require_relative 'test_data/account_creator'
require_relative 'test_data/inbox_creator'
require_relative 'test_data/display_id_tracker'
require_relative 'test_data/contact_batch_service'
require_relative 'test_data/orchestrator'

View File

@@ -0,0 +1,31 @@
class TestData::AccountCreator
DATA_FILE = 'tmp/test_data_account_ids.txt'.freeze
def self.create!(id)
company_name = generate_company_name
domain = generate_domain(company_name)
account = Account.create!(
id: id,
name: company_name,
domain: domain,
created_at: Faker::Time.between(from: 2.years.ago, to: 6.months.ago)
)
persist_account_id(account.id)
account
end
def self.generate_company_name
"#{Faker::Company.name} #{TestData::Constants::COMPANY_TYPES.sample}"
end
def self.generate_domain(company_name)
"#{company_name.parameterize}.#{TestData::Constants::DOMAIN_EXTENSIONS.sample}"
end
def self.persist_account_id(account_id)
FileUtils.mkdir_p('tmp')
File.open(DATA_FILE, 'a') do |file|
file.write("#{account_id},")
end
end
end

View File

@@ -0,0 +1,51 @@
class TestData::CleanupService
DATA_FILE = 'tmp/test_data_account_ids.txt'.freeze
class << self
def call
Rails.logger.info 'Cleaning up any existing test data...'
return log_no_file_found unless file_exists?
account_ids = parse_account_ids_from_file
if account_ids.any?
delete_accounts(account_ids)
else
log_no_accounts_found
end
delete_data_file
Rails.logger.info '==> Cleanup complete!'
end
private
def file_exists?
File.exist?(DATA_FILE)
end
def log_no_file_found
Rails.logger.info 'No test data file found, skipping cleanup'
end
def parse_account_ids_from_file
File.read(DATA_FILE).split(',').map(&:strip).reject(&:empty?).map(&:to_i)
end
def delete_accounts(account_ids)
Rails.logger.info "Found #{account_ids.size} test accounts to clean up: #{account_ids.join(', ')}"
start_time = Time.zone.now
Account.where(id: account_ids).destroy_all
Rails.logger.info "Deleted #{account_ids.size} accounts in #{Time.zone.now - start_time}s"
end
def log_no_accounts_found
Rails.logger.info 'No test account IDs found in the data file'
end
def delete_data_file
File.delete(DATA_FILE)
end
end
end

View File

@@ -0,0 +1,18 @@
module TestData::Constants
NUM_ACCOUNTS = 20
MIN_MESSAGES = 1_000_000 # 1M
MAX_MESSAGES = 10_000_000 # 10M
BATCH_SIZE = 5_000
MAX_CONVERSATIONS_PER_CONTACT = 20
INBOXES_PER_ACCOUNT = 5
STATUSES = %w[open resolved pending].freeze
MESSAGE_TYPES = %w[incoming outgoing].freeze
MIN_MESSAGES_PER_CONVO = 5
MAX_MESSAGES_PER_CONVO = 50
COMPANY_TYPES = %w[Retail Healthcare Finance Education Manufacturing].freeze
DOMAIN_EXTENSIONS = %w[com io tech ai].freeze
COUNTRY_CODES = %w[1 44 91 61 81 86 49 33 34 39].freeze # US, UK, India, Australia, Japan, China, Germany, France, Spain, Italy
end

View File

@@ -0,0 +1,196 @@
class TestData::ContactBatchService
def initialize(account:, inboxes:, batch_size:, display_id_tracker:)
@account = account
@inboxes = inboxes
@batch_size = batch_size
@display_id_tracker = display_id_tracker
@total_messages = 0
end
# Generates contacts, contact_inboxes, conversations, and messages
# Returns the total number of messages created in this batch
def generate!
Rails.logger.info { "Starting batch generation for account ##{@account.id} with #{@batch_size} contacts" }
create_contacts
create_contact_inboxes
create_conversations
create_messages
Rails.logger.info { "Completed batch with #{@total_messages} messages for account ##{@account.id}" }
@total_messages
end
private
# rubocop:disable Rails/SkipsModelValidations
def create_contacts
Rails.logger.info { "Creating #{@batch_size} contacts for account ##{@account.id}" }
start_time = Time.current
@contacts_data = Array.new(@batch_size) { build_contact_data }
Contact.insert_all!(@contacts_data) if @contacts_data.any?
@contacts = Contact
.where(account_id: @account.id)
.order(created_at: :desc)
.limit(@batch_size)
Rails.logger.info { "Contacts created in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
def build_contact_data
created_at = Faker::Time.between(from: 1.year.ago, to: Time.current)
{
account_id: @account.id,
name: Faker::Name.name,
email: "#{SecureRandom.uuid}@example.com",
phone_number: generate_e164_phone_number,
additional_attributes: maybe_add_additional_attributes,
created_at: created_at,
updated_at: created_at
}
end
def maybe_add_additional_attributes
return unless rand < 0.3
{
company: Faker::Company.name,
city: Faker::Address.city,
country: Faker::Address.country_code
}
end
def generate_e164_phone_number
return nil unless rand < 0.7
country_code = TestData::Constants::COUNTRY_CODES.sample
subscriber_number = rand(1_000_000..9_999_999_999).to_s
subscriber_number = subscriber_number[0...(15 - country_code.length)]
"+#{country_code}#{subscriber_number}"
end
# rubocop:disable Rails/SkipsModelValidations
def create_contact_inboxes
Rails.logger.info { "Creating contact inboxes for #{@contacts.size} contacts" }
start_time = Time.current
contact_inboxes_data = @contacts.flat_map do |contact|
@inboxes.map do |inbox|
{
inbox_id: inbox.id,
contact_id: contact.id,
source_id: SecureRandom.uuid,
created_at: contact.created_at,
updated_at: contact.created_at
}
end
end
count = contact_inboxes_data.size
ContactInbox.insert_all!(contact_inboxes_data) if contact_inboxes_data.any?
@contact_inboxes = ContactInbox.where(contact_id: @contacts.pluck(:id))
Rails.logger.info { "Created #{count} contact inboxes in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
# rubocop:disable Rails/SkipsModelValidations
def create_conversations
Rails.logger.info { "Creating conversations for account ##{@account.id}" }
start_time = Time.current
conversations_data = []
@contact_inboxes.each do |ci|
num_convos = rand(1..TestData::Constants::MAX_CONVERSATIONS_PER_CONTACT)
num_convos.times { conversations_data << build_conversation(ci) }
end
count = conversations_data.size
Rails.logger.info { "Preparing to insert #{count} conversations" }
Conversation.insert_all!(conversations_data) if conversations_data.any?
@conversations = Conversation.where(
account_id: @account.id,
display_id: conversations_data.pluck(:display_id)
).order(:created_at)
Rails.logger.info { "Created #{count} conversations in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
def build_conversation(contact_inbox)
created_at = Faker::Time.between(from: contact_inbox.created_at, to: Time.current)
{
account_id: @account.id,
inbox_id: contact_inbox.inbox_id,
contact_id: contact_inbox.contact_id,
contact_inbox_id: contact_inbox.id,
status: TestData::Constants::STATUSES.sample,
created_at: created_at,
updated_at: created_at,
display_id: @display_id_tracker.next_id
}
end
# rubocop:disable Rails/SkipsModelValidations
def create_messages
Rails.logger.info { "Creating messages for #{@conversations.size} conversations" }
start_time = Time.current
batch_count = 0
@conversations.find_in_batches(batch_size: 1000) do |batch|
batch_count += 1
batch_start = Time.current
messages_data = batch.flat_map do |convo|
build_messages_for_conversation(convo)
end
batch_message_count = messages_data.size
Rails.logger.info { "Preparing to insert #{batch_message_count} messages (batch #{batch_count})" }
Message.insert_all!(messages_data) if messages_data.any?
@total_messages += batch_message_count
Rails.logger.info { "Created batch #{batch_count} with #{batch_message_count} messages in #{Time.current - batch_start}s" }
end
Rails.logger.info { "Created total of #{@total_messages} messages in #{Time.current - start_time}s" }
end
# rubocop:enable Rails/SkipsModelValidations
def build_messages_for_conversation(conversation)
num_messages = rand(TestData::Constants::MIN_MESSAGES_PER_CONVO..TestData::Constants::MAX_MESSAGES_PER_CONVO)
message_type = TestData::Constants::MESSAGE_TYPES.sample
time_range = [conversation.created_at, Time.current]
generate_messages(conversation, num_messages, message_type, time_range)
end
def generate_messages(conversation, num_messages, initial_message_type, time_range)
message_type = initial_message_type
Array.new(num_messages) do
message_type = (message_type == 'incoming' ? 'outgoing' : 'incoming')
created_at = Faker::Time.between(from: time_range.first, to: time_range.last)
build_message_data(conversation, message_type, created_at)
end
end
def build_message_data(conversation, message_type, created_at)
{
account_id: @account.id,
inbox_id: conversation.inbox_id,
conversation_id: conversation.id,
message_type: message_type,
content: Faker::Lorem.paragraph(sentence_count: 2),
created_at: created_at,
updated_at: created_at,
private: false,
status: 'sent',
content_type: 'text',
source_id: SecureRandom.uuid
}
end
end

View File

@@ -0,0 +1,80 @@
class TestData::DatabaseOptimizer
class << self
# Tables that need trigger management
TABLES_WITH_TRIGGERS = %w[conversations messages].freeze
# Memory settings in MB
# Increased work_mem for better query performance with complex operations
WORK_MEM = 256
def setup
Rails.logger.info '==> Setting up database optimizations for improved performance'
# Remove statement timeout to allow long-running operations to complete
Rails.logger.info ' Removing statement timeout'
ActiveRecord::Base.connection.execute('SET statement_timeout = 0')
# Increase working memory for better query performance
Rails.logger.info " Increasing work_mem to #{WORK_MEM}MB"
ActiveRecord::Base.connection.execute("SET work_mem = '#{WORK_MEM}MB'")
# Set tables to UNLOGGED mode for better write performance
# This disables WAL completely for these tables
Rails.logger.info ' Setting tables to UNLOGGED mode'
set_tables_unlogged
# Disable triggers on specified tables to avoid overhead
Rails.logger.info ' Disabling triggers on specified tables'
disable_triggers
Rails.logger.info '==> Database optimizations complete, data generation will run faster'
end
def restore
Rails.logger.info '==> Restoring database settings to normal'
Rails.logger.info ' Re-enabling triggers on specified tables'
enable_triggers
Rails.logger.info ' Setting tables back to LOGGED mode'
set_tables_logged
# Reset memory settings to defaults
Rails.logger.info ' Resetting memory settings to defaults'
ActiveRecord::Base.connection.execute('RESET work_mem')
ActiveRecord::Base.connection.execute('RESET maintenance_work_mem')
Rails.logger.info '==> Database settings restored to normal operation'
end
private
def disable_triggers
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Disabling triggers on #{table} table"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} DISABLE TRIGGER ALL")
end
end
def enable_triggers
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Enabling triggers on #{table} table"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} ENABLE TRIGGER ALL")
end
end
def set_tables_unlogged
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Setting #{table} table as UNLOGGED"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} SET UNLOGGED")
end
end
def set_tables_logged
TABLES_WITH_TRIGGERS.each do |table|
Rails.logger.info " Setting #{table} table as LOGGED"
ActiveRecord::Base.connection.execute("ALTER TABLE #{table} SET LOGGED")
end
end
end
end

View File

@@ -0,0 +1,12 @@
class TestData::DisplayIdTracker
attr_reader :current
def initialize(account:)
max_display_id = Conversation.where(account_id: account.id).maximum(:display_id) || 0
@current = max_display_id
end
def next_id
@current += 1
end
end

View File

@@ -0,0 +1,12 @@
class TestData::InboxCreator
def self.create_for(account)
Array.new(TestData::Constants::INBOXES_PER_ACCOUNT) do
channel = Channel::Api.create!(account: account)
Inbox.create!(
account_id: account.id,
name: "API Inbox #{SecureRandom.hex(4)}",
channel: channel
)
end
end
end

View File

@@ -0,0 +1,109 @@
class TestData::Orchestrator
class << self
def call
Rails.logger.info { '========== STARTING TEST DATA GENERATION ==========' }
cleanup_existing_data
set_start_id
Rails.logger.info { "Starting to generate distributed test data across #{TestData::Constants::NUM_ACCOUNTS} accounts..." }
Rails.logger.info do
"Each account have between #{TestData::Constants::MIN_MESSAGES / 1_000_000}M and #{TestData::Constants::MAX_MESSAGES / 1_000_000}M messages"
end
TestData::Constants::NUM_ACCOUNTS.times do |account_index|
Rails.logger.info { "Processing account #{account_index + 1} of #{TestData::Constants::NUM_ACCOUNTS}" }
process_account(account_index)
end
Rails.logger.info { "========== ALL DONE! Created #{TestData::Constants::NUM_ACCOUNTS} accounts with distributed test data ==========" }
end
private
# Simple value object to group generation parameters
class DataGenerationParams
attr_reader :account, :inboxes, :total_contacts_needed, :target_message_count, :display_id_tracker
def initialize(account:, inboxes:, total_contacts_needed:, target_message_count:, display_id_tracker:)
@account = account
@inboxes = inboxes
@total_contacts_needed = total_contacts_needed
@target_message_count = target_message_count
@display_id_tracker = display_id_tracker
end
end
# 1. Remove existing data for old test accounts
def cleanup_existing_data
Rails.logger.info { 'Cleaning up existing test data...' }
TestData::CleanupService.call
Rails.logger.info { 'Cleanup complete' }
end
# 2. Find the max Account ID to avoid conflicts
def set_start_id
max_id = Account.maximum(:id) || 0
@start_id = max_id + 1
Rails.logger.info { "Setting start ID to #{@start_id}" }
end
# 3. Create an account, its inboxes, and some data
def process_account(account_index)
account_id = @start_id + account_index
Rails.logger.info { "Creating account with ID #{account_id}" }
account = TestData::AccountCreator.create!(account_id)
inboxes = TestData::InboxCreator.create_for(account)
target_messages = rand(TestData::Constants::MIN_MESSAGES..TestData::Constants::MAX_MESSAGES)
avg_per_convo = rand(15..50)
total_convos = (target_messages / avg_per_convo.to_f).ceil
total_contacts = (total_convos / TestData::Constants::MAX_CONVERSATIONS_PER_CONTACT.to_f).ceil
log_account_details(account, target_messages, total_contacts, total_convos)
display_id_tracker = TestData::DisplayIdTracker.new(account: account)
params = DataGenerationParams.new(
account: account,
inboxes: inboxes,
total_contacts_needed: total_contacts,
target_message_count: target_messages,
display_id_tracker: display_id_tracker
)
Rails.logger.info { "Starting data generation for account ##{account.id}" }
generate_data_for_account(params)
end
def generate_data_for_account(params)
contact_count = 0
message_count = 0
batch_number = 0
while contact_count < params.total_contacts_needed
batch_number += 1
batch_size = [TestData::Constants::BATCH_SIZE, params.total_contacts_needed - contact_count].min
Rails.logger.info { "Processing batch ##{batch_number} (#{batch_size} contacts) for account ##{params.account.id}" }
batch_service = TestData::ContactBatchService.new(
account: params.account,
inboxes: params.inboxes,
batch_size: batch_size,
display_id_tracker: params.display_id_tracker
)
batch_created_messages = batch_service.generate!
contact_count += batch_size
message_count += batch_created_messages
end
Rails.logger.info { "==> Completed Account ##{params.account.id} with #{message_count} messages" }
end
def log_account_details(account, target_messages, total_contacts, total_convos)
Rails.logger.info { "==> Account ##{account.id} plan: target of #{target_messages / 1_000_000.0}M messages" }
Rails.logger.info { " Planning for #{total_contacts} contacts and #{total_convos} conversations" }
end
end
end

11
lib/url_helper.rb Normal file
View File

@@ -0,0 +1,11 @@
require 'uri'
module UrlHelper
def url_valid?(url)
url = begin
URI.parse(url)
rescue StandardError
false
end
url.is_a?(URI::HTTP) || url.is_a?(URI::HTTPS)
end
end

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