Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29db73d655 | ||
|
|
1345f67966 | ||
|
|
ecd4892a23 | ||
|
|
457430e8d9 | ||
|
|
e13e3c873a | ||
|
|
0346e9a2c7 | ||
|
|
7e4d93f649 | ||
|
|
b2ffad1998 | ||
|
|
0f914fa2ab | ||
|
|
59663dd558 |
+1
-1
@@ -1 +1 @@
|
||||
4.10.0
|
||||
4.10.1
|
||||
|
||||
@@ -24,9 +24,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def search
|
||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||
|
||||
contacts = resolved_contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
|
||||
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||
contacts = Current.account.contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts = fetch_contacts(contacts)
|
||||
|
||||
@@ -8,7 +8,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
|
||||
def update
|
||||
@hook.update!(permitted_params.slice(:status, :settings))
|
||||
@hook.reauthorized! if @hook.reauthorization_required?
|
||||
end
|
||||
|
||||
def process_event
|
||||
@@ -19,7 +18,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
if response.nil?
|
||||
render json: { message: nil }
|
||||
elsif response[:error]
|
||||
render json: { error: response[:error], error_type: response[:error_type] }, status: :unprocessable_entity
|
||||
render json: { error: response[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: { message: response[:message] }
|
||||
end
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
/**
|
||||
* Cleans and normalizes a list of labels.
|
||||
@@ -156,8 +155,6 @@ export function useAI() {
|
||||
}
|
||||
};
|
||||
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
/**
|
||||
* Processes an AI event, such as rephrasing content.
|
||||
* @param {string} [type='rephrase'] - The type of AI event to process.
|
||||
@@ -176,29 +173,11 @@ export function useAI() {
|
||||
} = result;
|
||||
return generatedMessage;
|
||||
} catch (error) {
|
||||
const errorData = error.response?.data;
|
||||
const errorData = error.response.data.error;
|
||||
const errorMessage =
|
||||
errorData?.error?.message ||
|
||||
errorData?.error ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
|
||||
const errorType = errorData?.error_type;
|
||||
if (errorType === 'rate_limit') {
|
||||
useAlert(t('INTEGRATION_SETTINGS.OPEN_AI.RATE_LIMIT_ERROR'), {
|
||||
duration: 5000,
|
||||
});
|
||||
} else if (errorType === 'auth') {
|
||||
useAlert(t('INTEGRATION_SETTINGS.OPEN_AI.AUTH_ERROR'), {
|
||||
type: 'link',
|
||||
to: frontendURL(
|
||||
`accounts/${accountId.value}/settings/integrations/openai`
|
||||
),
|
||||
message: t('INTEGRATION_SETTINGS.OPEN_AI.GO_TO_SETTINGS'),
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
useAlert(errorMessage);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,14 +57,6 @@ export const useIntegrationHook = integrationId => {
|
||||
return integrationType.value === 'single';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether any hook needs reauthorization (e.g., API key issues)
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const hookNeedsReauthorization = computed(() => {
|
||||
return integration.value.hooks?.some(hook => hook.reauthorization_required);
|
||||
});
|
||||
|
||||
return {
|
||||
integration,
|
||||
integrationType,
|
||||
@@ -72,6 +64,5 @@ export const useIntegrationHook = integrationId => {
|
||||
isIntegrationSingle,
|
||||
isHookTypeInbox,
|
||||
hasConnectedHooks,
|
||||
hookNeedsReauthorization,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,13 +55,8 @@
|
||||
"DISCONNECT": {
|
||||
"BUTTON_TEXT": "Disconnect"
|
||||
},
|
||||
"REAUTHORIZE": {
|
||||
"BUTTON_TEXT": "Disconnect",
|
||||
"DESCRIPTION": "Your API key may be invalid or your credit balance may be exhausted. Please disconnect and reconnect with a valid API key.",
|
||||
"OPENAI_LINK": "Check your OpenAI dashboard"
|
||||
},
|
||||
"SIDEBAR_DESCRIPTION": {
|
||||
"DIALOGFLOW": "Dialogflow is a natural language processing platform for building conversational interfaces. Integrating it with {installationName} lets bots handle queries first and transfer them to agents when needed. It helps qualify leads and reduce agent workload by answering FAQs. To add Dialogflow, create a Service Account in Google Console and share the credentials. Refer to the docs for details"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +184,7 @@
|
||||
"GENERATING": "Generating...",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"GENERATE_ERROR": "Could not process request. Check your API key, credits, or rate limits.",
|
||||
"RATE_LIMIT_ERROR": "Rate limit exceeded. Check your OpenAI usage tier.",
|
||||
"AUTH_ERROR": "Could not authenticate. Check your API key or credits.",
|
||||
"GO_TO_SETTINGS": "Click here to update"
|
||||
"GENERATE_ERROR": "There was an error processing the content, please verify your OpenAI API key and try again"
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { computed, watch, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -49,10 +49,14 @@ const handleCreateClose = () => {
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
onMounted(() =>
|
||||
store.dispatch('captainInboxes/get', {
|
||||
assistantId: assistantId.value,
|
||||
})
|
||||
watch(
|
||||
assistantId,
|
||||
newId => {
|
||||
store.dispatch('captainInboxes/get', {
|
||||
assistantId: newId,
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
+39
-54
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
integrationId: {
|
||||
@@ -14,68 +13,54 @@ const props = defineProps({
|
||||
|
||||
defineEmits(['add', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { integration, hasConnectedHooks, hookNeedsReauthorization } =
|
||||
useIntegrationHook(props.integrationId);
|
||||
const { integration, hasConnectedHooks } = useIntegrationHook(
|
||||
props.integrationId
|
||||
);
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Banner v-if="hookNeedsReauthorization" color="ruby">
|
||||
{{ t('INTEGRATION_APPS.REAUTHORIZE.DESCRIPTION') }}
|
||||
<a
|
||||
href="https://platform.openai.com/account/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline font-medium"
|
||||
>
|
||||
{{ t('INTEGRATION_APPS.REAUTHORIZE.OPENAI_LINK') }}
|
||||
</a>
|
||||
</Banner>
|
||||
<div
|
||||
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center">
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}.png`"
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
/>
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
|
||||
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
|
||||
{{ integration.name }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-sm leading-6">
|
||||
{{ replaceInstallationName(integration.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center items-center mb-0 w-[15%]">
|
||||
<div v-if="hasConnectedHooks">
|
||||
<div @click="$emit('delete', integration.hooks[0])">
|
||||
<Button
|
||||
ruby
|
||||
faded
|
||||
:label="$t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center">
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}.png`"
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
/>
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
|
||||
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
|
||||
{{ integration.name }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-sm leading-6">
|
||||
{{ replaceInstallationName(integration.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center items-center mb-0 w-[15%]">
|
||||
<div v-if="hasConnectedHooks">
|
||||
<div @click="$emit('delete', integration.hooks[0])">
|
||||
<Button
|
||||
blue
|
||||
ruby
|
||||
faded
|
||||
:label="$t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT')"
|
||||
@click="$emit('add')"
|
||||
:label="$t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Button
|
||||
blue
|
||||
faded
|
||||
:label="$t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT')"
|
||||
@click="$emit('add')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,4 @@ class AdministratorNotifications::IntegrationsNotificationMailer < Administrator
|
||||
subject = 'Your Dialogflow integration was disconnected'
|
||||
send_notification(subject)
|
||||
end
|
||||
|
||||
def openai_disconnect
|
||||
subject = 'Your OpenAI integration needs attention'
|
||||
action_url = settings_url('integrations/openai')
|
||||
send_notification(subject, action_url: action_url)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
|
||||
after_create :sync_templates
|
||||
before_destroy :teardown_webhooks
|
||||
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
|
||||
|
||||
def name
|
||||
'Whatsapp'
|
||||
@@ -86,4 +87,10 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
def teardown_webhooks
|
||||
Whatsapp::WebhookTeardownService.new(self).perform
|
||||
end
|
||||
|
||||
def should_auto_setup_webhooks?
|
||||
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
|
||||
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
|
||||
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,8 +49,6 @@ module Reauthorizable
|
||||
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later
|
||||
elsif dialogflow?
|
||||
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later
|
||||
elsif openai?
|
||||
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).openai_disconnect.deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -60,10 +60,6 @@ class Integrations::Hook < ApplicationRecord
|
||||
app_id == 'notion'
|
||||
end
|
||||
|
||||
def openai?
|
||||
app_id == 'openai'
|
||||
end
|
||||
|
||||
def disable
|
||||
update(status: 'disabled')
|
||||
end
|
||||
|
||||
@@ -16,6 +16,10 @@ class Whatsapp::EmbeddedSignupService
|
||||
validate_token_access(access_token)
|
||||
|
||||
channel = create_or_reauthorize_channel(access_token, phone_info)
|
||||
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
|
||||
# 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger
|
||||
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
|
||||
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
|
||||
channel.setup_webhooks
|
||||
check_channel_health_and_prompt_reauth(channel)
|
||||
channel
|
||||
|
||||
@@ -10,10 +10,7 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
|
||||
|
||||
def download_attachment_file(attachment_payload)
|
||||
url_response = HTTParty.get(
|
||||
inbox.channel.media_url(
|
||||
attachment_payload[:id],
|
||||
inbox.channel.provider_config['phone_number_id']
|
||||
),
|
||||
inbox.channel.media_url(attachment_payload[:id]),
|
||||
headers: inbox.channel.api_headers
|
||||
)
|
||||
# This url response will be failure if the access token has expired.
|
||||
|
||||
@@ -75,10 +75,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
csat_template_service.get_template_status(template_name)
|
||||
end
|
||||
|
||||
def media_url(media_id, phone_number_id = nil)
|
||||
url = "#{api_base_path}/v13.0/#{media_id}"
|
||||
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
|
||||
url
|
||||
def media_url(media_id)
|
||||
"#{api_base_path}/v13.0/#{media_id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -4,7 +4,6 @@ json.status resource.enabled?
|
||||
json.inbox resource.inbox&.slice(:id, :name)
|
||||
json.account_id resource.account_id
|
||||
json.hook_type resource.hook_type
|
||||
json.reauthorization_required resource.reauthorization_required?
|
||||
|
||||
json.settings resource.settings if Current.account_user&.administrator?
|
||||
json.reference_id resource.reference_id if Current.account_user&.administrator?
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Your OpenAI integration needs attention. This could be due to an expired or invalid API key, or your OpenAI credit balance may be exhausted.</p>
|
||||
|
||||
<p>To continue using AI features, please update your API key in the integration settings.</p>
|
||||
|
||||
<p>
|
||||
Click <a href="{{action_url}}">here</a> to update your API key.
|
||||
</p>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.10.0'
|
||||
version: '4.10.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -4,9 +4,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
end
|
||||
description 'Search conversations based on parameters'
|
||||
|
||||
param :status, type: :string, desc: 'Status of the conversation'
|
||||
param :status, type: :string, desc: 'Status of the conversation (open, resolved, pending, snoozed). Leave empty to search all statuses.'
|
||||
param :contact_id, type: :number, desc: 'Contact id'
|
||||
param :priority, type: :string, desc: 'Priority of conversation'
|
||||
param :priority, type: :string, desc: 'Priority of conversation (low, medium, high, urgent). Leave empty to search all priorities.'
|
||||
param :labels, type: :string, desc: 'Labels available'
|
||||
|
||||
def execute(status: nil, contact_id: nil, priority: nil, labels: nil)
|
||||
@@ -19,7 +19,7 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
|
||||
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
|
||||
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true, include_private_messages: true) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
@@ -34,12 +34,20 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
def get_conversations(status, contact_id, priority, labels)
|
||||
conversations = permissible_conversations
|
||||
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
|
||||
conversations = conversations.where(status: status) if status.present?
|
||||
conversations = conversations.where(priority: priority) if priority.present?
|
||||
conversations = conversations.where(status: status) if valid_status?(status)
|
||||
conversations = conversations.where(priority: priority) if valid_priority?(priority)
|
||||
conversations = conversations.tagged_with(labels, any: true) if labels.present?
|
||||
conversations
|
||||
end
|
||||
|
||||
def valid_status?(status)
|
||||
status.present? && Conversation.statuses.key?(status)
|
||||
end
|
||||
|
||||
def valid_priority?(priority)
|
||||
priority.present? && Conversation.priorities.key?(priority)
|
||||
end
|
||||
|
||||
def permissible_conversations
|
||||
Conversations::PermissionFilterService.new(
|
||||
@assistant.account.conversations,
|
||||
|
||||
@@ -96,9 +96,6 @@ class Integrations::LlmBaseService
|
||||
end
|
||||
end
|
||||
|
||||
RATE_LIMIT_ERRORS = [RubyLLM::RateLimitError].freeze
|
||||
AUTH_ERRORS = [RubyLLM::UnauthorizedError, RubyLLM::PaymentRequiredError, RubyLLM::ForbiddenError].freeze
|
||||
|
||||
def execute_ruby_llm_request(parsed_body)
|
||||
messages = parsed_body['messages']
|
||||
model = parsed_body['model']
|
||||
@@ -107,26 +104,11 @@ class Integrations::LlmBaseService
|
||||
chat = context.chat(model: model)
|
||||
setup_chat_with_messages(chat, messages)
|
||||
end
|
||||
rescue *RATE_LIMIT_ERRORS => e
|
||||
handle_rate_limit_error(e, messages)
|
||||
rescue *AUTH_ERRORS => e
|
||||
handle_auth_error(e, messages)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
|
||||
build_error_response_from_exception(e, messages)
|
||||
end
|
||||
|
||||
def handle_rate_limit_error(error, messages)
|
||||
Rails.logger.warn "[LLM] Rate limit error for hook #{hook.id}: #{error.message}"
|
||||
build_error_response_from_exception(error, messages, error_type: 'rate_limit')
|
||||
end
|
||||
|
||||
def handle_auth_error(error, messages)
|
||||
Rails.logger.warn "[LLM] Auth error for hook #{hook.id}: #{error.class} - #{error.message}"
|
||||
hook.authorization_error!
|
||||
build_error_response_from_exception(error, messages, error_type: 'auth')
|
||||
end
|
||||
|
||||
def setup_chat_with_messages(chat, messages)
|
||||
apply_system_instructions(chat, messages)
|
||||
response = send_conversation_messages(chat, messages)
|
||||
@@ -181,9 +163,7 @@ class Integrations::LlmBaseService
|
||||
}
|
||||
end
|
||||
|
||||
def build_error_response_from_exception(error, messages, error_type: nil)
|
||||
response = { error: error.message, request_messages: messages }
|
||||
response[:error_type] = error_type if error_type
|
||||
response
|
||||
def build_error_response_from_exception(error, messages)
|
||||
{ error: error.message, request_messages: messages }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.10.0",
|
||||
"version": "4.10.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -119,5 +119,42 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
|
||||
result = service.execute(status: 'snoozed')
|
||||
expect(result).to eq('No conversations found')
|
||||
end
|
||||
|
||||
context 'when invalid status is provided' do
|
||||
it 'ignores invalid status and returns all conversations' do
|
||||
result = service.execute(status: 'all')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'ignores random invalid status values' do
|
||||
result = service.execute(status: 'invalid_status')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid priority is provided' do
|
||||
it 'ignores invalid priority and returns all conversations' do
|
||||
result = service.execute(priority: 'all')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'ignores random invalid priority values' do
|
||||
result = service.execute(priority: 'invalid_priority')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when combining valid and invalid parameters' do
|
||||
it 'applies valid filters and ignores invalid ones' do
|
||||
result = service.execute(status: 'all', contact_id: contact.id)
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -96,8 +96,16 @@ FactoryBot.define do
|
||||
channel_whatsapp.define_singleton_method(:sync_templates) { nil } unless options.sync_templates
|
||||
channel_whatsapp.define_singleton_method(:validate_provider_config) { nil } unless options.validate_provider_config
|
||||
if channel_whatsapp.provider == 'whatsapp_cloud'
|
||||
channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge({ 'api_key' => 'test_key', 'phone_number_id' => '123456789',
|
||||
'business_account_id' => '123456789' })
|
||||
# Add 'source' => 'embedded_signup' to skip after_commit :setup_webhooks callback in tests
|
||||
# The callback is for manual setup flow; embedded signup handles webhook setup explicitly
|
||||
# Only set source if not already provided (allows tests to override)
|
||||
default_config = {
|
||||
'api_key' => 'test_key',
|
||||
'phone_number_id' => '123456789',
|
||||
'business_account_id' => '123456789'
|
||||
}
|
||||
default_config['source'] = 'embedded_signup' unless channel_whatsapp.provider_config.key?('source')
|
||||
channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge(default_config)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -47,16 +47,39 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
|
||||
describe 'webhook_verify_token' do
|
||||
before do
|
||||
# Stub webhook setup to prevent HTTP calls during channel creation
|
||||
setup_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
|
||||
allow(setup_service).to receive(:perform)
|
||||
end
|
||||
|
||||
it 'generates webhook_verify_token if not present' do
|
||||
channel = create(:channel_whatsapp, provider_config: { webhook_verify_token: nil }, provider: 'whatsapp_cloud', account: create(:account),
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel = create(:channel_whatsapp,
|
||||
provider_config: {
|
||||
'webhook_verify_token' => nil,
|
||||
'api_key' => 'test_key',
|
||||
'business_account_id' => '123456789'
|
||||
},
|
||||
provider: 'whatsapp_cloud',
|
||||
account: create(:account),
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
|
||||
expect(channel.provider_config['webhook_verify_token']).not_to be_nil
|
||||
end
|
||||
|
||||
it 'does not generate webhook_verify_token if present' do
|
||||
channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', provider_config: { webhook_verify_token: '123' }, account: create(:account),
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel = create(:channel_whatsapp,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'webhook_verify_token' => '123',
|
||||
'api_key' => 'test_key',
|
||||
'business_account_id' => '123456789'
|
||||
},
|
||||
account: create(:account),
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
|
||||
expect(channel.provider_config['webhook_verify_token']).to eq '123'
|
||||
end
|
||||
@@ -91,15 +114,18 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
|
||||
context 'when channel is created through manual setup' do
|
||||
it 'does not setup webhooks' do
|
||||
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
|
||||
it 'setups webhooks via after_commit callback' do
|
||||
expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
expect(webhook_service).to receive(:perform)
|
||||
|
||||
# Explicitly set source to nil to test manual setup behavior (not embedded_signup)
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token'
|
||||
'api_key' => 'test_access_token',
|
||||
'source' => nil
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
@@ -157,12 +183,17 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
|
||||
context 'when channel is not embedded_signup' do
|
||||
it 'does not call WebhookTeardownService on destroy' do
|
||||
it 'calls WebhookTeardownService on destroy' do
|
||||
# Mock the setup service to prevent HTTP calls during creation
|
||||
setup_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
|
||||
allow(setup_service).to receive(:perform)
|
||||
|
||||
channel = create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'source' => 'manual',
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
|
||||
@@ -41,10 +41,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
it 'increments reauthorization count if fetching attachment fails' do
|
||||
stub_request(
|
||||
:get,
|
||||
whatsapp_channel.media_url(
|
||||
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||
whatsapp_channel.provider_config['phone_number_id']
|
||||
)
|
||||
whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
|
||||
).to_return(
|
||||
status: 401
|
||||
)
|
||||
@@ -112,10 +109,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
def stub_media_url_request
|
||||
stub_request(
|
||||
:get,
|
||||
whatsapp_channel.media_url(
|
||||
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||
whatsapp_channel.provider_config['phone_number_id']
|
||||
)
|
||||
whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
|
||||
).to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
|
||||
@@ -6,7 +6,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
phone_number: '+1234567890',
|
||||
provider_config: {
|
||||
'phone_number_id' => '123456789',
|
||||
'webhook_verify_token' => 'test_verify_token'
|
||||
'webhook_verify_token' => 'test_verify_token',
|
||||
'source' => 'embedded_signup'
|
||||
},
|
||||
provider: 'whatsapp_cloud',
|
||||
sync_templates: false,
|
||||
@@ -261,7 +262,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
'phone_number_id' => '123456789',
|
||||
'webhook_verify_token' => 'existing_verify_token',
|
||||
'business_id' => 'existing_business_id',
|
||||
'waba_id' => 'existing_waba_id'
|
||||
'waba_id' => 'existing_waba_id',
|
||||
'source' => 'embedded_signup'
|
||||
},
|
||||
provider: 'whatsapp_cloud',
|
||||
sync_templates: false,
|
||||
|
||||
Reference in New Issue
Block a user