Built-in AI chatbot (no EE, uses Llm::Resolver) that answers in-scope chats
from a knowledge base and hands off to a human when needed. Selected per
inbox via an Integrations::Hook with app_id 'chatbot'.
- Integrations::Chatbot::ProcessorService (mirrors Dialogflow/Captain)
wired via HookListener + HookJob + apps.yml(chatbot, inbox)
- Chatbot::DecisionService: 1-call default ({in_scope/refuse/handoff}),
2-call option; thread-safe prompt threading
- Chatbot::KnowledgeRetriever: keyword top-k over KB (+ embedding reserved)
- KnowledgeBaseFaq + import service (md per-heading + front-matter tags)
- Chatbot::ConfigService + admin chatbot_config endpoint
- refs off-topic (e.g. fortune-telling); handoff = bot_handoff! (pending->open)
112 lines
4.5 KiB
Ruby
112 lines
4.5 KiB
Ruby
class HookJob < MutexApplicationJob
|
|
retry_on LockAcquisitionError, wait: 3.seconds, attempts: 3
|
|
|
|
queue_as :medium
|
|
|
|
INTEGRATION_PROCESSORS = {
|
|
'slack' => :process_slack_integration,
|
|
'dialogflow' => :process_dialogflow_integration,
|
|
'chatbot' => :process_chatbot_integration,
|
|
'google_translate' => :google_translate_integration,
|
|
'leadsquared' => :process_leadsquared_integration_with_lock,
|
|
'linear' => :process_linear_integration
|
|
}.freeze
|
|
|
|
def perform(hook, event_name, event_data = {})
|
|
return if hook.disabled?
|
|
|
|
processor = INTEGRATION_PROCESSORS[hook.app_id]
|
|
send(processor, hook, event_name, event_data) if processor
|
|
rescue StandardError => e
|
|
Rails.logger.error e
|
|
end
|
|
|
|
private
|
|
|
|
def process_slack_integration(hook, event_name, event_data)
|
|
message = event_data[:message]
|
|
|
|
case event_name
|
|
when 'message.created'
|
|
if message.attachments.blank?
|
|
::SendOnSlackJob.perform_later(message, hook)
|
|
else
|
|
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
|
end
|
|
when 'message.updated'
|
|
# Only interactive bot messages store responses via content_attributes (submitted_values / submitted_email).
|
|
# Skip other content types to avoid unnecessary job enqueues on every message update.
|
|
return unless message.content_type.in?(Integrations::Slack::UpdateSlackMessageService::SUPPORTED_CONTENT_TYPES)
|
|
# Guard against redundant Slack updates when unrelated attributes change (e.g. status)
|
|
# while submitted_values is already present on the message.
|
|
return unless event_data[:previous_changes]&.key?('content_attributes')
|
|
|
|
::UpdateSlackMessageJob.perform_later(message, hook)
|
|
end
|
|
end
|
|
|
|
def process_dialogflow_integration(hook, event_name, event_data)
|
|
return unless ['message.created', 'message.updated'].include?(event_name)
|
|
|
|
Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
|
|
end
|
|
|
|
def process_chatbot_integration(hook, event_name, event_data)
|
|
return unless event_name == 'message.created'
|
|
|
|
message = event_data[:message]
|
|
return unless message.content_type == 'text' && message.content.present?
|
|
|
|
Integrations::Chatbot::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
|
|
end
|
|
|
|
def google_translate_integration(hook, event_name, event_data)
|
|
return unless ['message.created'].include?(event_name)
|
|
|
|
message = event_data[:message]
|
|
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
|
|
end
|
|
|
|
def process_linear_integration(hook, event_name, event_data)
|
|
return unless event_name == 'message.created'
|
|
|
|
message = event_data[:message]
|
|
Integrations::Linear::AutoLinkService.new(account: hook.account, message: message).perform
|
|
end
|
|
|
|
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
|
# Why do we need a mutex here? glad you asked
|
|
# When a new conversation is created. We get a contact created event, immediately followed by
|
|
# a contact updated event, and then a conversation created event.
|
|
# This all happens within milliseconds of each other.
|
|
# Now each of these subsequent event handlers need to have a leadsquared lead created and the contact to have the ID.
|
|
# If the lead data is not present, we try to search the API and create a new lead if it doesn't exist.
|
|
# This gives us a bad race condition that allows the API to create multiple leads for the same contact.
|
|
#
|
|
# This would have not been a problem if the email and phone number were unique identifiers for contacts at LeadSquared
|
|
# But then this is configurable in the LeadSquared settings, and may or may not be unique.
|
|
valid_event_names = ['contact.updated', 'conversation.created', 'conversation.resolved']
|
|
return unless valid_event_names.include?(event_name)
|
|
return unless hook.feature_allowed?
|
|
|
|
key = format(::Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: hook.id)
|
|
with_lock(key) do
|
|
process_leadsquared_integration(hook, event_name, event_data)
|
|
end
|
|
end
|
|
|
|
def process_leadsquared_integration(hook, event_name, event_data)
|
|
# Process the event with the processor service
|
|
processor = Crm::Leadsquared::ProcessorService.new(hook)
|
|
|
|
case event_name
|
|
when 'contact.updated'
|
|
processor.handle_contact(event_data[:contact])
|
|
when 'conversation.created'
|
|
processor.handle_conversation_created(event_data[:conversation])
|
|
when 'conversation.resolved'
|
|
processor.handle_conversation_resolved(event_data[:conversation])
|
|
end
|
|
end
|
|
end
|