Merge branch 'feature/ai-137' into feature/ai-164

This commit is contained in:
Aakash Bakhle
2026-05-04 18:44:17 +05:30
committed by GitHub
40 changed files with 630 additions and 144 deletions
+3
View File
@@ -11,6 +11,9 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deployment_check:
name: Check Deployment
+3
View File
@@ -8,6 +8,9 @@ on:
branches:
- develop
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
@@ -10,6 +10,9 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
log_lines_check:
runs-on: ubuntu-latest
+3
View File
@@ -14,6 +14,9 @@ on:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
runs-on: ubuntu-24.04
@@ -3,6 +3,10 @@ name: Publish Codespace Base Image
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
+3
View File
@@ -18,6 +18,9 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
@@ -18,6 +18,9 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
+3
View File
@@ -10,6 +10,9 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
+3
View File
@@ -7,6 +7,9 @@ on:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
test-build:
strategy:
+6 -2
View File
@@ -7,11 +7,15 @@ class UserDrop < BaseDrop
@obj.try(:available_name)
end
def email
@obj.try(:email)
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
end
def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
end
end
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMessageContext } from '../provider.js';
import { VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -29,14 +30,16 @@ const BG_COLOR_MAP = {
const { t } = useI18n();
const store = useStore();
const { call, conversationId, currentUserId } = useMessageContext();
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallSession();
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
@@ -52,8 +55,7 @@ const conversationAssignee = computed(() => {
return conversation?.meta?.assignee || null;
});
const displayAgentName = computed(() => {
if (call.value?.accepted_by_agent_name)
return call.value.accepted_by_agent_name;
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
@@ -104,6 +106,45 @@ const iconName = computed(() => {
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const callSid = computed(() => call.value?.providerCallId);
// Show "Join call" when the call is still ringing, no agent has claimed it,
// and the conversation is unassigned or assigned to the current user. Mirrors
// the eligibility used by FloatingCallWidget so the bubble can act as a
// recovery affordance after a refresh or missed widget.
const canJoinCall = computed(() => {
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
if (isOutbound.value) return false;
if (acceptedByAgentId.value) return false;
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
// Suppress the button once this call is the local active session — the
// message status webhook may lag behind, so we can't rely on `status` alone
// to hide it after a successful join from this client.
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
return false;
const assignee = conversationAssignee.value;
if (assignee?.id && assignee.id !== currentUserId.value) return false;
return true;
});
const handleJoinCall = async () => {
if (!canJoinCall.value || isJoining.value) return;
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
await endCall({
conversationId: activeCall.value.conversationId,
inboxId: activeCall.value.inboxId,
callSid: activeCall.value.callSid,
});
}
await joinCall({
conversationId: conversationId.value,
inboxId: inboxId.value,
callSid: callSid.value,
});
};
</script>
<template>
@@ -131,6 +172,15 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
<span class="text-xs text-n-slate-11">
{{ subtext }}
</span>
<button
v-if="canJoinCall"
type="button"
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
:disabled="isJoining"
@click="handleJoinCall"
>
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
</button>
</div>
</div>
</div>
@@ -84,7 +84,8 @@
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered"
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -1,5 +1,6 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import store from '../../../store';
import ConversationView from './ConversationView.vue';
const CONVERSATION_PERMISSIONS = [
@@ -10,6 +11,37 @@ const CONVERSATION_PERMISSIONS = [
'conversation_participating_manage',
];
const isFolderAvailable = async folderId => {
let folders = store.getters['customViews/getConversationCustomViews'];
if (!folders.length) {
await store.dispatch('customViews/get', 'conversation');
folders = store.getters['customViews/getConversationCustomViews'];
}
return folders.some(folder => folder.id === Number(folderId));
};
const redirectFolderListIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({ name: 'home', params: { accountId: to.params.accountId } });
};
const redirectFolderConversationIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({
name: 'inbox_conversation',
params: {
accountId: to.params.accountId,
conversation_id: to.params.conversation_id,
},
});
};
export default {
routes: [
{
@@ -113,6 +145,7 @@ export default {
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
beforeEnter: redirectFolderListIfUnavailable,
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
},
@@ -125,6 +158,7 @@ export default {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
beforeEnter: redirectFolderConversationIfUnavailable,
props: route => ({
conversationId: route.params.conversation_id,
foldersId: route.params.id,
@@ -0,0 +1,7 @@
class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob
queue_as :scheduled_jobs
def perform; end
end
Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob')
+1 -1
View File
@@ -11,7 +11,7 @@ module Liquidable
def message_drops
{
'contact' => ContactDrop.new(conversation.contact),
'agent' => UserDrop.new(sender),
'agent' => UserDrop.new(sender || conversation.assignee),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(inbox),
'account' => AccountDrop.new(conversation.account)
+39 -15
View File
@@ -1,6 +1,8 @@
require 'net/imap'
class Imap::BaseFetchEmailService
MAX_MESSAGES_PER_SYNC = 500
pattr_initialize [:channel!, :interval]
def fetch_emails
@@ -77,27 +79,49 @@ class Imap::BaseFetchEmailService
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
message_ids_with_seq = []
seq_nums.each_slice(10).each do |batch|
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
# Check
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
next
end
batch_message_ids.each do |data|
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
seq_nums.each_slice(MAX_MESSAGES_PER_SYNC).each do |batch|
append_message_ids_for_batch(batch, message_ids_with_seq)
if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Reached MAX_MESSAGES_PER_SYNC=#{MAX_MESSAGES_PER_SYNC} for #{channel.email}, stopping sync."
break
end
end
message_ids_with_seq
end
def append_message_ids_for_batch(batch, message_ids_with_seq)
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch for #{channel.email}. Found #{batch_message_ids&.length} messages."
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
return
end
batch_message_ids.each do |data|
entry = build_message_id_entry(data)
next if entry.nil?
message_ids_with_seq.push(entry)
break if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
end
end
def build_message_id_entry(data)
mail = build_mail_from_string(data.attr['BODY[HEADER]'])
return nil if MailPresenter.new(mail, channel.account).notification_email_from_chatwoot?
message_id = mail.message_id
return nil if message_id.blank?
return nil if email_already_present?(channel, message_id)
[data.seqno, message_id]
end
# Sends a SEARCH command to search the mailbox for messages that were
# created between yesterday (or given date) and today and returns message sequence numbers.
# Return <message set>
@@ -205,3 +205,5 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
process_response(response, message)
end
end
Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService')
-5
View File
@@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do
if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file)
# Merge enterprise-only cron entries when running an enterprise build.
# Mirrors the conditional-load pattern already used for enterprise initializers.
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
# Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal.
+6
View File
@@ -209,6 +209,12 @@
description: 'The limits for the Captain AI service for different plans'
value:
type: code
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS
display_title: 'Captain Document Auto Sync Intervals'
description: 'JSON map of plan-wise Captain document auto-sync intervals in hours. Use null to disable auto-sync for a plan.'
value:
locked: false
type: code
# End of Captain Config
# ------- Context.dev Config ------- #
+1
View File
@@ -245,6 +245,7 @@ en:
deleted: This message was deleted
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
+6
View File
@@ -14,6 +14,12 @@ trigger_scheduled_items_job:
class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs
# executed hourly for scheduled jobs that do not need minute-level cadence
trigger_hourly_scheduled_items_job:
cron: '0 * * * *'
class: 'Internal::TriggerHourlyScheduledItemsJob'
queue: scheduled_jobs
# executed At every minute..
trigger_imap_email_inboxes_job:
cron: '*/1 * * * *'
@@ -35,6 +35,9 @@ module Captain::ChatResponseHelper
def credit_used_for_response?(parsed_response)
response = parsed_response['response']
# The classifier can still decide to hand off after this trace is written.
# Actual response usage is charged later in ResponseBuilderJob, so billing stays correct.
response.present? && response != 'conversation_handoff' && parsed_response['action'] != 'handoff'
end
@@ -106,7 +106,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def v1_handoff_requested?
@response['action'] == 'handoff' || legacy_v1_handoff_token?
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
end
def classifier_v1_handoff_requested?
@response['action'] == 'handoff'
end
def legacy_v1_handoff_token?
@@ -179,7 +183,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def handle_error(error)
log_error(error)
@response ||= { 'action_source' => 'error', 'action_reason' => 'response_builder_error' }
@response ||= {}
@response['action_source'] ||= 'error'
@response['action_reason'] ||= error_action_reason(error)
process_v1_handoff if conversation_pending?
true
end
@@ -188,6 +194,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
ChatwootExceptionTracker.new(error, account: account).capture_exception
end
def error_action_reason(error)
error.class.name.underscore.tr('/', '_')
end
def captain_v2_enabled?
account.feature_enabled?('captain_integration_v2')
end
@@ -25,13 +25,7 @@ module Captain::Conversation::V1ActionClassifier
def apply_v1_action_classification(classification)
action = classification['action']
unless action.in?(%w[continue handoff])
Rails.logger.warn(
"[CAPTAIN][ResponseBuilderJob] V1 action classifier returned invalid action for account=#{account.id} " \
"conversation=#{@conversation.display_id}: #{classification['error'] || classification['raw_response']}"
)
return
end
return log_invalid_v1_action_classification(classification) unless valid_v1_action_classification?(action)
@response.merge!(
'action' => action,
@@ -51,4 +45,15 @@ module Captain::Conversation::V1ActionClassifier
"prompt_version=#{classification['prompt_version']}"
)
end
def valid_v1_action_classification?(action)
Captain::AssistantActionSchema::ACTIONS.include?(action)
end
def log_invalid_v1_action_classification(classification)
Rails.logger.warn(
'[CAPTAIN][ResponseBuilderJob] V1 action classifier returned invalid action; falling back to assistant response ' \
"for account=#{account.id} conversation=#{@conversation.display_id}: #{classification['error'] || classification['raw_response']}"
)
end
end
@@ -7,6 +7,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
def perform
@remaining_global_capacity = GLOBAL_HOURLY_CAP
sync_intervals = Enterprise::Account.captain_document_sync_intervals
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
@@ -16,7 +17,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
next unless account.feature_enabled?('captain_document_auto_sync')
stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval
interval = account.captain_document_sync_interval(sync_intervals)
next unless interval
stats[:accounts_scheduled] += 1
@@ -0,0 +1,7 @@
module Enterprise::Internal::TriggerHourlyScheduledItemsJob
def perform
super
Captain::Documents::ScheduleSyncsJob.perform_later
end
end
+23 -8
View File
@@ -1,10 +1,22 @@
module Enterprise::Account
CAPTAIN_SYNC_INTERVALS = {
'hacker' => nil,
'startups' => 7.days,
'business' => 1.day,
'enterprise' => 6.hours
}.freeze
class << self
def captain_document_sync_intervals
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
end
private
def parse_captain_document_sync_intervals(configured_intervals)
return {} if configured_intervals.blank?
parsed_intervals = configured_intervals.is_a?(String) ? JSON.parse(configured_intervals) : configured_intervals
return {} unless parsed_intervals.is_a?(Hash)
parsed_intervals.transform_keys { |plan| plan.to_s.downcase }
rescue JSON::ParserError
{}
end
end
# TODO: Remove this when we upgrade administrate gem to the latest version
# this is a temporary method since current administrate doesn't support virtual attributes
@@ -41,12 +53,15 @@ module Enterprise::Account
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
def captain_document_sync_interval
def captain_document_sync_interval(sync_intervals = Enterprise::Account.captain_document_sync_intervals)
plan = custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
return nil if plan.blank?
CAPTAIN_SYNC_INTERVALS[plan.downcase]
interval_hours = sync_intervals[plan.downcase]
return nil unless interval_hours.is_a?(Integer) && interval_hours.positive?
interval_hours.hours
end
def saml_enabled?
@@ -2,25 +2,24 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
include Integrations::LlmInstrumentation
PROMPT_VERSION = 'v1_custom_xml_precedence'.freeze
DEFAULT_MODEL = 'gpt-4.1'.freeze
MAX_CONTEXT_MESSAGES = 10
VALID_ACTIONS = %w[continue handoff].freeze
def initialize(assistant:, conversation:)
super()
@assistant = assistant
@conversation = conversation
@model = DEFAULT_MODEL
@temperature = 0.0
end
def classify(message_history:, assistant_response:)
payload = classification_payload(message_history, assistant_response)
user_prompt = classification_user_prompt(payload)
user_prompt = classification_user_prompt(
message_history: message_history,
assistant_response: assistant_response
)
response = instrument_llm_call(instrumentation_params(user_prompt)) do
chat(model: @model, temperature: @temperature)
.with_params(response_format: { type: 'json_object' })
.with_schema(Captain::AssistantActionSchema)
.with_instructions(system_prompt)
.ask(user_prompt)
end
@@ -37,33 +36,18 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
private
def classification_payload(message_history, assistant_response)
normalized_messages = normalize_messages(message_history)
{
'account_custom_instructions' => account_custom_instructions,
'conversation_context' => context_messages(normalized_messages),
'current_user_message' => current_user_message(normalized_messages),
'assistant_response_to_classify' => assistant_response.to_s
}
end
def classification_user_prompt(payload)
def classification_user_prompt(message_history:, assistant_response:)
<<~PROMPT
<account_custom_instructions>
#{payload['account_custom_instructions']}
#{@assistant.config['instructions']}
</account_custom_instructions>
<conversation_context>
#{payload['conversation_context'].to_json}
#{format_conversation_context(message_history)}
</conversation_context>
<current_user_message>
#{payload['current_user_message']}
</current_user_message>
<assistant_response_to_classify>
#{payload['assistant_response_to_classify']}
#{assistant_response}
</assistant_response_to_classify>
PROMPT
end
@@ -90,21 +74,25 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
(part[:type] || part['type']).to_s == 'text'
end
def current_user_message(messages)
messages.reverse.find { |message| message[:role] == 'user' }&.dig(:content).to_s
def format_conversation_context(messages)
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
content = message[:content].to_s.strip
next if content.blank?
"#{role_label(message[:role])}: #{content}"
end.join("\n")
end
def context_messages(messages)
current_user_index = messages.rindex { |message| message[:role] == 'user' }
prior_messages = current_user_index ? messages[0...current_user_index] : messages
prior_messages.last(MAX_CONTEXT_MESSAGES)
end
def role_label(role)
return 'User' if role == 'user'
return 'Assistant' if role == 'assistant'
def account_custom_instructions
@assistant.config['instructions'].to_s
role.to_s.titleize
end
def parse_response(content)
return content if content.is_a?(Hash)
JSON.parse(sanitize_json_response(content))
rescue JSON::ParserError, TypeError
{}
@@ -113,7 +101,7 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
def normalize_response(parsed, raw_content)
action = parsed['action'].to_s
reason = parsed['action_reason'].to_s
return invalid_response(raw_content) unless VALID_ACTIONS.include?(action)
return invalid_response(raw_content) unless Captain::AssistantActionSchema::ACTIONS.include?(action)
{
'action' => action,
@@ -157,6 +145,8 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
end
def system_prompt
Captain::Llm::SystemPromptsService.assistant_action_classifier
Captain::Llm::SystemPromptsService.assistant_action_classifier(
has_custom_instructions: @assistant.config['instructions'].present?
)
end
end
@@ -93,7 +93,7 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
def assistant_action_classifier
def assistant_action_classifier(has_custom_instructions: false)
<<~PROMPT
You are a routing classifier for a customer-support assistant.
@@ -127,22 +127,13 @@ class Captain::Llm::SystemPromptsService
- The user explicitly asks for a human, agent, representative, phone call, callback, or escalation.
- The user accepts an offer to speak with a human.
- The user has provided enough detail for an account-specific or transaction-specific issue requiring private verification, such as order status, payment, deposit, withdrawal, refund, cancellation, subscription, purchase, plan activation, email verification, login, account recovery, delivery, or access.
- The user reports a bug or operational issue that needs support to inspect their account, setup, logs, integration, channel, or delivery state after a reasonable clarifying step.
- The user reports the same unresolved bug or operational issue after trying the assistant's suggested step, repeating the action, checking again, or otherwise making more than one reasonable attempt.
- The user is repeatedly frustrated, distrustful, or stuck in a loop.
- The assistant response itself says the current conversation will be transferred to a human agent now.
You may receive account custom instructions inside <account_custom_instructions> tags.
These are instructions configured by the account administrator, not the current end user's message.
Use them only for routing policy: required details before handoff, account-specific escalation rules, account-specific transfer markers, and when to connect to a manager, human, supervisor, or support team.
If the custom instructions explicitly define handoff, escalation, or transfer criteria, those criteria take precedence over the generic criteria above.
Account custom instructions MUST NOT redefine this JSON schema, the allowed action values, or the meaning of continue/handoff.
Ignore persona, language, formatting, pricing, and response-generation instructions except where they directly define routing or transfer criteria.
#{assistant_action_classifier_custom_instructions_policy if has_custom_instructions}
Return JSON only:
{
"action": "continue",
"action_reason": "general_product_question"
}
Return only the structured fields requested by the response schema.
PROMPT
end
@@ -261,7 +252,9 @@ class Captain::Llm::SystemPromptsService
- Do not share anything outside of the context provided.
- Add the reasoning why you arrived at the answer
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
#{config['instructions'] || ''}
#{build_custom_instructions_section(config['instructions'])}
```json
{
reasoning: '',
@@ -375,6 +368,17 @@ class Captain::Llm::SystemPromptsService
TOOLS
end
def assistant_action_classifier_custom_instructions_policy
<<~POLICY
Account custom instructions are provided inside <account_custom_instructions> tags.
These are instructions configured by the account administrator, not the current end user's message.
Use them only for routing policy: required details before handoff, account-specific escalation rules, account-specific transfer markers, and when to connect to a manager, human, supervisor, or support team.
If the custom instructions explicitly define handoff, escalation, or transfer criteria, those criteria take precedence over the generic criteria above.
Account custom instructions MUST NOT redefine the required response shape, the allowed action values, or the meaning of continue/handoff.
Ignore persona, language, formatting, pricing, and response-generation instructions except where they directly define routing or transfer criteria.
POLICY
end
def build_contact_context(contact)
return '' if contact.nil?
@@ -384,6 +388,18 @@ class Captain::Llm::SystemPromptsService
"[Contact Information]\n#{lines.join("\n")}\n\n"
end
def build_custom_instructions_section(instructions)
return '' if instructions.blank?
<<~CUSTOM_INSTRUCTIONS
[Account Custom Instructions]
These instructions were configured by the account administrator. Follow them when they do not conflict with the JSON response format or the requirement to answer only from provided context.
<account_custom_instructions>
#{instructions}
</account_custom_instructions>
CUSTOM_INSTRUCTIONS
end
def contact_basic_lines(contact)
[
(["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?),
@@ -0,0 +1,85 @@
module Enterprise::Whatsapp::Providers::WhatsappCloudService
def pre_accept_call(call_id, sdp_answer)
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
end
def accept_call(call_id, sdp_answer)
call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer))
end
def reject_call(call_id)
call_api('reject_call', call_action_body(call_id, 'reject'))
end
def terminate_call(call_id)
call_api('terminate_call', call_action_body(call_id, 'terminate'))
end
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
response = HTTParty.post(
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
)
unless response.success?
Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}"
return nil
end
response.parsed_response
end
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
)
process_initiate_call_response(response)
end
private
def call_action_body(call_id, action, sdp_answer = nil)
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
body
end
def call_api(action_name, body)
url = "#{phone_id_path}/calls"
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
response.success?
end
def permission_request_body(to_phone_number, body_text)
{
messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number,
type: 'interactive',
interactive: {
type: 'call_permission_request',
action: { name: 'call_permission_request' },
body: { text: body_text }
}
}.to_json
end
def initiate_call_body(to_phone_number, sdp_offer)
{
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
session: { sdp: sdp_offer, sdp_type: 'offer' }
}.to_json
end
def process_initiate_call_response(response)
return response.parsed_response if response.success?
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
error_code = parsed.dig('error', 'code')
error_msg = parsed.dig('error', 'error_user_msg') || 'Failed to initiate call'
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE
raise Voice::CallErrors::CallFailed, error_msg
end
end
-10
View File
@@ -1,10 +0,0 @@
# Enterprise-only Sidekiq cron schedule.
# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true.
# Add cron entries here when the referenced job class lives under enterprise/.
# Captain document auto-sync scheduler
# Runs hourly, finds due documents based on plan sync intervals
captain_documents_schedule_syncs_job:
cron: '0 * * * *'
class: 'Captain::Documents::ScheduleSyncsJob'
queue: scheduled_jobs
@@ -0,0 +1,20 @@
class Captain::AssistantActionSchema < RubyLLM::Schema
ACTIONS = %w[continue handoff].freeze
REASONS = %w[
general_product_question
missing_docs_bounded_answer
clarifying_question_needed
collect_required_identifier
external_contact_or_lead_routing
out_of_scope_bounded_answer
explicit_human_request
human_offer_accepted
account_or_transaction_verification
operational_issue_needs_inspection
repeated_frustration_or_loop
custom_instruction_transfer
].freeze
string :action, enum: ACTIONS, description: 'Whether to keep the conversation with the assistant or transfer it to a human agent'
string :action_reason, enum: REASONS, description: 'The reason for the selected routing action'
end
+11
View File
@@ -0,0 +1,11 @@
module Voice::CallErrors
# Meta WhatsApp Cloud Calling error code returned when the contact has not
# granted call permission yet. See `initiate_call` in
# Enterprise::Whatsapp::Providers::WhatsappCloudService.
NO_CALL_PERMISSION_CODE = 138_006
class NoCallPermission < StandardError; end
class CallFailed < StandardError; end
class NotRinging < StandardError; end
class AlreadyAccepted < StandardError; end
end
@@ -398,9 +398,11 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
it 'handles API errors and triggers handoff' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
allow(Rails.logger).to receive(:info).and_call_original
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(Rails.logger).to have_received(:info).with(include('source=error reason=faraday_bad_request_error'))
end
it 'succeeds when no error occurs' do
@@ -5,6 +5,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
let(:assistant) { create(:captain_assistant, account: account) }
before do
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
account.enable_features!('captain_document_auto_sync')
clear_enqueued_jobs
end
+48 -26
View File
@@ -225,46 +225,68 @@ RSpec.describe Account, type: :model do
describe 'captain document sync cadence' do
let(:account) { create(:account) }
it 'has no cadence on the hacker plan' do
account.update!(custom_attributes: { plan_name: 'hacker' })
expect(account.captain_document_sync_interval).to be_nil
end
it 'syncs weekly on the startups plan' do
account.update!(custom_attributes: { plan_name: 'startups' })
expect(account.captain_document_sync_interval).to eq(7.days)
end
it 'syncs daily on the business plan' do
it 'has no cadence when installation config is missing' do
account.update!(custom_attributes: { plan_name: 'business' })
expect(account.captain_document_sync_interval).to eq(1.day)
end
it 'syncs every six hours on the enterprise plan' do
account.update!(custom_attributes: { plan_name: 'enterprise' })
expect(account.captain_document_sync_interval).to eq(6.hours)
end
it 'has no cadence when plan is missing' do
account.update!(custom_attributes: {})
expect(account.captain_document_sync_interval).to be_nil
end
it 'has no cadence for unknown plans' do
account.update!(custom_attributes: { plan_name: 'mystery' })
expect(account.captain_document_sync_interval).to be_nil
it 'uses configured plan intervals from installation config' do
intervals = {
business: 48,
enterprise: 24
}
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
account.update!(custom_attributes: { plan_name: 'business' })
expect(account.captain_document_sync_interval).to eq(2.days)
end
it 'normalizes plan name casing' do
it 'normalizes configured plan name casing' do
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24 }.to_json)
account.update!(custom_attributes: { plan_name: 'Business' })
expect(account.captain_document_sync_interval).to eq(1.day)
end
it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do
it 'uses the enterprise cadence for self-hosted enterprise installs without a plan_name' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { enterprise: 6 }.to_json)
account.update!(custom_attributes: {})
expect(account.captain_document_sync_interval).to eq(6.hours)
end
it 'allows installation config to disable a plan cadence' do
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: nil }.to_json)
account.update!(custom_attributes: { plan_name: 'business' })
expect(account.captain_document_sync_interval).to be_nil
end
it 'has no cadence when installation config is invalid' do
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: 'invalid-json')
account.update!(custom_attributes: { plan_name: 'business' })
expect(account.captain_document_sync_interval).to be_nil
end
it 'treats invalid plan interval values as disabled' do
intervals = {
business: false,
enterprise: { hours: 6 },
startups: '168'
}
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
account.update!(custom_attributes: { plan_name: 'business' })
expect(account.captain_document_sync_interval).to be_nil
account.update!(custom_attributes: { plan_name: 'enterprise' })
expect(account.captain_document_sync_interval).to be_nil
account.update!(custom_attributes: { plan_name: 'startups' })
expect(account.captain_document_sync_interval).to be_nil
end
end
describe 'account deletion' do
@@ -17,14 +17,14 @@ RSpec.describe Captain::Llm::AssistantActionClassifierService do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: '{"action":"handoff","action_reason":"human_offer_accepted"}'
content: { 'action' => 'handoff', 'action_reason' => 'human_offer_accepted' }
)
end
before do
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
end
@@ -38,24 +38,25 @@ RSpec.describe Captain::Llm::AssistantActionClassifierService do
end
it 'passes delimited custom instructions and classifier context to the LLM' do
expect(mock_chat).to receive(:with_schema).with(Captain::AssistantActionSchema).and_return(mock_chat)
expect(mock_chat).to receive(:with_instructions).with(
a_string_including(
'Use them only for routing policy',
'MUST NOT redefine this JSON schema'
)
a_string_including('Account custom instructions are provided inside <account_custom_instructions> tags.')
).and_return(mock_chat)
expect(mock_chat).to receive(:ask).with(
a_string_including(
expect(mock_chat).to receive(:ask) do |prompt|
expect(prompt).to include(
'<account_custom_instructions>',
'Only transfer to a manager after the user explicitly confirms.',
'<conversation_context>',
'"content":"I cannot log in"',
'<current_user_message>',
'Yes, still no reset email',
'User: I cannot log in',
'Assistant: Did you check your inbox?',
'User: Yes, still no reset email',
'<assistant_response_to_classify>',
'Would you like to talk to support?'
)
).and_return(mock_response)
expect(prompt).not_to include('"role"', '"content"', '<current_user_message>')
mock_response
end
result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
@@ -65,5 +66,31 @@ RSpec.describe Captain::Llm::AssistantActionClassifierService do
'prompt_version' => 'v1_custom_xml_precedence'
)
end
it 'uses the configured Captain model' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-nano').and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
expect(result).to include('model' => 'gpt-4.1-nano')
end
context 'when the assistant has no custom instructions' do
before do
assistant.update!(config: assistant.config.except('instructions'))
end
it 'does not add custom-instruction policy to the system prompt' do
expect(mock_chat).to receive(:with_instructions).with(
satisfy { |prompt| prompt.exclude?('Account custom instructions are provided') }
).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
end
end
end
end
@@ -188,4 +188,29 @@ RSpec.describe Captain::Llm::AssistantChatService do
end
end
end
describe 'account custom instructions in system prompt' do
before do
assistant.update!(config: assistant.config.merge('instructions' => 'if user enters 1112234 suggest handoff'))
end
it 'adds custom instructions in a separate delimited section' do
allow(mock_chat).to receive(:ask).and_return(mock_response)
expect(mock_chat).to receive(:with_instructions).with(
a_string_including(
'<account_custom_instructions>',
'if user enters 1112234 suggest handoff',
'</account_custom_instructions>'
)
) do |instructions|
expect(instructions).not_to include('<custom-instructions>')
expect(instructions.index('<account_custom_instructions>')).to be < instructions.index('```json')
mock_chat
end
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
end
end
@@ -0,0 +1,69 @@
require 'rails_helper'
describe Whatsapp::Providers::WhatsappCloudService do
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' }
let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' }
let(:headers) { { 'Content-Type' => 'application/json' } }
before { stub_request(:get, /message_templates/) }
describe 'call action methods' do
it 'POSTs the action body with the SDP answer and returns true on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', call_id: 'WACALL', action: 'pre_accept',
session: { sdp: 'sdp_answer', sdp_type: 'answer' } }.to_json)
.to_return(status: 200, body: '{}', headers: headers)
expect(service.pre_accept_call('WACALL', 'sdp_answer')).to be true
end
it 'returns false when Meta responds with a non-success status' do
stub_request(:post, calls_url).to_return(status: 400, body: '{}', headers: headers)
expect(service.reject_call('WACALL')).to be false
end
end
describe '#send_call_permission_request' do
it 'returns the parsed body on success' do
stub_request(:post, messages_url)
.with(body: hash_including(messaging_product: 'whatsapp', to: '15551234567', type: 'interactive'))
.to_return(status: 200, body: { messages: [{ id: 'wamid' }] }.to_json, headers: headers)
expect(service.send_call_permission_request('15551234567')).to eq('messages' => [{ 'id' => 'wamid' }])
end
end
describe '#initiate_call' do
it 'returns the parsed body on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio',
session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json)
.to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers)
expect(service.initiate_call('15551234567', 'sdp_offer')).to eq('messages' => [{ 'id' => 'wacall_1' }])
end
it 'raises Voice::CallErrors::NoCallPermission when Meta returns error code 138006' do
stub_request(:post, calls_url).to_return(
status: 400,
body: { error: { code: 138_006, error_user_msg: 'No call permission' } }.to_json,
headers: headers
)
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::NoCallPermission, 'No call permission')
end
it 'raises Voice::CallErrors::CallFailed with a fallback message when the error body is non-JSON' do
stub_request(:post, calls_url).to_return(status: 502, body: '<html>502 Bad Gateway</html>',
headers: { 'Content-Type' => 'text/html' })
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::CallFailed, 'Failed to initiate call')
end
end
end
@@ -7,6 +7,7 @@ RSpec.describe Imap::FetchEmailService do
let(:imap_email_channel) { create(:channel_email, :imap_email, account: account) }
let(:imap) { instance_double(Net::IMAP) }
let(:eml_content_with_message_id) { Rails.root.join('spec/fixtures/files/only_text.eml').read }
let(:eml_content_without_message_id) { eml_content_with_message_id.sub(/^Message-ID:.*\n/, '') }
describe '#perform' do
before do
@@ -63,6 +64,34 @@ RSpec.describe Imap::FetchEmailService do
expect(imap).not_to have_received(:fetch).with(1, 'RFC822')
end
end
it 'does not count emails without message ids toward the sync limit' do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
max_messages_per_sync = Imap::BaseFetchEmailService::MAX_MESSAGES_PER_SYNC
empty_message_id_seq_nums = (1..max_messages_per_sync).to_a
valid_message_seq_num = max_messages_per_sync + 1
empty_message_id_headers = empty_message_id_seq_nums.map do |seq_num|
Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id)
end
valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id)
imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num])
allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers)
allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header])
allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: imap_email_channel).perform
expect(result.length).to eq 1
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]')
expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]')
expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822')
end
end
end
end
end