Merge branch 'develop' into feature/ai-137
This commit is contained in:
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ------- #
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 * * * *'
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user