Compare commits

..
157 changed files with 2235 additions and 3144 deletions
+1 -1
View File
@@ -1 +1 @@
4.16.1
4.16.0
+3 -19
View File
@@ -2,14 +2,6 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -22,23 +14,15 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
@@ -1,4 +1,5 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
@@ -1,6 +1,8 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
def update
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
emails = params[:emails]
bulk_create_agents(emails)
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
private
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def bulk_create_agents(emails)
Current.account.with_lock do
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
emails.each { |email| create_agent_from_email(email) }
end
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
def create_agent_from_email(email)
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
def clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
end
def available_agent_count
Current.account.usage_limits[:agents] - Current.account.account_users.count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end
def delete_user_record(agent)
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -1,9 +0,0 @@
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
private
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
def check_authorization
authorize(:hook)
end
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, except: [:create]
before_action :check_authorization
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
@hook = Current.account.hooks.find(params[:id])
end
def check_authorization
authorize(:hook)
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
@@ -1,6 +1,5 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
@hook.destroy!
@@ -1,8 +1,7 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
@@ -1,4 +1,5 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
@@ -19,13 +18,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
def ensure_embedded_signup_enabled
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
raise Pundit::NotAuthorizedError
end
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -52,7 +44,8 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
return true if ChatwootApp.chatwoot_cloud?
# Reconfiguring a live embedded-signup channel requires the feature flag.
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
@@ -0,0 +1,77 @@
class Api::V1::Accounts::Whatsapp::ManualSetupController < Api::V1::Accounts::BaseController
before_action :authorize_create, only: [:preview, :connect]
before_action :fetch_inbox, only: [:webhook_status, :setup_webhook]
def preview
render json: validation_service.perform
rescue StandardError => e
render_setup_error(e)
end
def connect
setup = Whatsapp::ManualSetupService.new(account: Current.account, **connect_params.to_h.symbolize_keys).perform
render json: connection_response(setup), status: :created
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_setup_error(e)
end
def webhook_status
render json: Whatsapp::ManualWebhookStatusService.new(@inbox.channel).perform
rescue StandardError => e
render_setup_error(e)
end
def setup_webhook
channel = @inbox.channel
Whatsapp::WebhookSetupService.new(channel).register_callback
render json: Whatsapp::ManualWebhookStatusService.new(channel.reload).perform
rescue StandardError => e
render_setup_error(e)
end
private
def authorize_create
authorize ::Inbox, :create?
end
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :update?
channel = @inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.provider_config['source'] == 'manual_setup_v2'
raise ActiveRecord::RecordNotFound
end
def validation_service
Whatsapp::ManualSetupValidationService.new(**connection_params.to_h.symbolize_keys)
end
def connection_params
params.permit(:waba_id, :phone_number_id, :access_token)
end
def connect_params
params.permit(:waba_id, :phone_number_id, :access_token, :inbox_name)
end
def connection_response(setup)
channel = setup.channel.reload
{
id: channel.inbox.id,
name: channel.inbox.name,
number_access: true,
template_access: true,
webhook_setup: setup.webhook_setup?,
webhook_error: setup.webhook_error
}
end
def render_setup_error(error)
Rails.logger.error "[WHATSAPP MANUAL SETUP] account_id=#{Current.account.id} error=#{error.class}: #{error.message}"
render json: { message: error.message }, status: :unprocessable_entity
end
end
@@ -47,10 +47,18 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
end
def inactive_whatsapp_number?
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class CaptainAgentSessions extends ApiClient {
constructor() {
super('captain/agent_sessions', { accountScoped: true });
}
}
export default new CaptainAgentSessions();
@@ -26,25 +26,15 @@ class CaptainAssistant extends ApiClient {
});
}
getMetrics({ assistantId, range, signal }) {
const requestConfig = {
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
});
}
getFaqStats({ assistantId, signal }) {
const requestConfig = {};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
}
getSummary({ assistantId, range, stats }) {
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset(), stats },
params: { range, timezone_offset: getTimezoneOffset() },
});
}
@@ -10,13 +10,10 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
contact_id: contactId,
inbox_id: inboxId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
@@ -16,6 +16,26 @@ class WhatsappChannel extends ApiClient {
inbox_id: inboxId,
});
}
previewManualSetup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/manual/preview`, params);
}
connectManualSetup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/manual/connect`, params);
}
getManualWebhookStatus(inboxId) {
return axios.get(
`${this.baseUrl()}/whatsapp/manual/${inboxId}/webhook_status`
);
}
setupManualWebhook(inboxId) {
return axios.post(
`${this.baseUrl()}/whatsapp/manual/${inboxId}/setup_webhook`
);
}
}
export default new WhatsappChannel();
@@ -25,11 +25,8 @@ const route = useRoute();
const kind = computed(() => getCallKind(props.call));
const contactName = computed(() =>
(props.call.contact.name || props.call.contact.phoneNumber || '').replace(
/^\+/,
''
)
const contactName = computed(
() => props.call.contact.name || props.call.contact.phoneNumber
);
const agentActionLabel = computed(() => {
@@ -16,6 +16,7 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -82,18 +83,39 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
const response = await whatsappCallSession.initiateOutboundCall(
conversationIdHint
? { conversationId: conversationIdHint }
: { contactId: props.contactId, inboxId }
);
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
@@ -234,7 +234,6 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
:close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -9,7 +9,6 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -46,11 +45,7 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div v-if="loading" class="flex items-end justify-between gap-2">
<div class="w-20 rounded h-9 bg-n-slate-3 animate-pulse" />
<div class="w-10 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-else class="flex items-end justify-between gap-2">
<div class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
@@ -9,10 +9,6 @@ const props = defineProps({
type: String,
default: '30',
},
stats: {
type: Object,
default: null,
},
});
const route = useRoute();
@@ -24,41 +20,22 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
// Increments on every fetch so a slow response for a superseded
// range/stats/assistant can't overwrite the latest request's state.
let fetchToken = 0;
const fetchSummary = async () => {
fetchToken += 1;
const token = fetchToken;
if (!props.stats) {
welcomeMarkdown.value = '';
isLoading.value = false;
return;
}
isLoading.value = true;
let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
stats: props.stats,
});
message = data.message ?? '';
welcomeMarkdown.value = data.message ?? '';
} catch {
message = '';
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
}
if (token !== fetchToken) return;
welcomeMarkdown.value = message;
isLoading.value = false;
};
watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
immediate: true,
});
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
@@ -3,13 +3,8 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import {
isTwilioComplete,
isTwilioMediaTemplate,
getTwilioMediaVariableKey,
getTwilioMediaUrl,
applyTwilioMediaFilename,
} from '@chatwoot/utils';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -45,23 +40,30 @@ const templateBody = computed(() => {
return props.template.body || '';
});
// Media-template detection and variable extraction are shared with the mobile
// app via @chatwoot/utils.
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() =>
getTwilioMediaVariableKey(props.template)
);
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const templateMediaUrl = computed(() =>
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
);
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
@@ -81,10 +83,26 @@ const renderedTemplate = computed(() => {
return rendered;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isTwilioComplete(props.template, processedParams.value)
);
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
const v$ = useVuelidate(
{
@@ -117,11 +135,19 @@ const sendMessage = () => {
const { friendly_name, language } = props.template;
// For media templates, reduce the media URL to a filename before sending.
const processedParameters = applyTwilioMediaFilename(
props.template,
processedParams.value
);
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
const payload = {
message: renderedTemplate.value,
@@ -1,342 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMessageContext } from './provider.js';
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
const props = defineProps({
messageId: { type: Number, required: true },
});
const { t } = useI18n();
const { orientation, variant, createdAt } = useMessageContext();
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const isOpen = ref(false);
const showSparkle = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const session = computed(() =>
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
);
const hasFetched = computed(() =>
store.getters['captainAgentSessions/hasFetched'](props.messageId)
);
const isLoading = computed(
() =>
!hasFetched.value ||
store.getters['captainAgentSessions/isFetching'](props.messageId)
);
const citations = computed(() => session.value?.citations || []);
const scenarioTitles = computed(() =>
(session.value?.scenarios || []).reduce((map, scenario) => {
map[scenario.id] = scenario.title;
return map;
}, {})
);
// Fallback for agents without a matching scenario title:
// "chatwoot_assistant" → "Chatwoot assistant",
// "scenario_5_chatwoot_uptime_agent" → "Chatwoot uptime".
const humanizeAgentName = agentName => {
const label = agentName
.replace(/^scenario_\d+_/, '')
.replace(/_agent$/, '')
.replaceAll('_', ' ')
.trim();
return label.charAt(0).toUpperCase() + label.slice(1);
};
const handoffLabel = agentName => {
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
};
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
// Tool names arrive as RubyLLM identifiers like
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
// show "FAQ Lookup" / "Get Status Page Overview" instead.
const humanizeToolName = name => {
return (name || '')
.split('--')
.pop()
.replace(/^custom_/, '')
.split('_')
.filter(Boolean)
.map(word =>
ACRONYMS.includes(word)
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join(' ');
};
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
const humanizeArgumentKey = key =>
key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const formatArguments = args => {
if (!args || typeof args !== 'object') return '';
return Object.entries(args)
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
.join(', ');
};
// Timeline of what Captain did during the run: tool calls (with their
// arguments) and scenario/agent handoffs. Message bodies and raw tool
// results are intentionally not echoed here.
const steps = computed(() => {
const runContext = session.value?.runContext;
const result = [];
let currentAgent = null;
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
if (entry?.role !== 'assistant') return;
const agentName = entry.agentName;
if (agentName && agentName !== currentAgent) {
if (currentAgent !== null) {
result.push({ type: 'handoff', name: handoffLabel(agentName) });
}
currentAgent = agentName;
}
(entry.toolCalls || []).forEach(call => {
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
// the agent_name change above already yields a handoff step for them.
if (call.name?.startsWith('handoff_to_')) return;
result.push({
type: 'tool',
name: humanizeToolName(call.name),
detail: formatArguments(call.arguments),
});
});
});
return result;
});
// The final assistant entry stores structured content ({response, reasoning});
// surface the model's reasoning for the reply it produced.
const reasoning = computed(() => {
const runContext = session.value?.runContext;
if (!Array.isArray(runContext)) return '';
const entry = [...runContext]
.reverse()
.find(item => item?.role === 'assistant' && item.content?.reasoning);
return entry?.content?.reasoning || '';
});
const STEP_ICONS = {
tool: 'i-ph-wrench',
handoff: 'i-ph-user-switch',
};
const STEP_KEYPATHS = {
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
};
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
// Model and credits are only surfaced to super admins and in development.
const devDetails = computed(() => {
if (!session.value) return null;
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
model: session.value.llmModel,
});
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
credits: session.value.creditsConsumed,
});
return `${model} · ${credits}`;
});
// With the sparkle at the row start, the meta gets pushed to the opposite end;
// without it, fall back to the message orientation.
const rowLayoutClass = computed(() => {
if (showSparkle.value) return 'justify-between';
return orientation.value === ORIENTATION.LEFT
? 'justify-start'
: 'justify-end';
});
// Blend the sparkle with the bubble background: amber on private notes,
// slate everywhere else. Tokens adapt to dark mode on their own.
const sparkleColorClass = computed(() => {
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
return isOpen.value
? 'text-n-amber-12/80'
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
}
return isOpen.value
? 'text-n-slate-12'
: 'text-n-slate-11/60 hover:text-n-slate-12';
});
const popoverAlign = computed(() =>
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
);
const prefetch = () => {
store.dispatch('captainAgentSessions/fetch', {
messageId: props.messageId,
createdAt: createdAt.value,
});
};
const onPopoverShow = () => {
isOpen.value = true;
prefetch();
};
const onPopoverHide = () => {
isOpen.value = false;
};
</script>
<template>
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
<Popover
v-if="showSparkle"
:align="popoverAlign"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<button
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
type="button"
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
:class="sparkleColorClass"
@mouseenter="prefetch"
@focus="prefetch"
>
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
<span class="text-xs">
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
</span>
</button>
<template #content>
<div class="flex flex-col gap-4 p-4 w-80">
<span v-if="isLoading" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
</span>
<span v-else-if="!session" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
</span>
<template v-else>
<div v-if="steps.length" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
</span>
<div class="flex flex-col">
<div
v-for="(step, index) in steps"
:key="index"
class="flex gap-2.5"
>
<div class="flex flex-col items-center">
<span
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
>
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 w-px min-h-2 bg-n-weak"
/>
</div>
<div
class="flex flex-col min-w-0 gap-0.5"
:class="index < steps.length - 1 ? 'pb-3' : ''"
>
<I18nT
:keypath="STEP_KEYPATHS[step.type]"
tag="span"
class="text-xs leading-5 text-n-slate-11"
>
<template #name>
<span class="font-medium text-n-slate-12">
{{ step.name }}
</span>
</template>
</I18nT>
<span
v-if="step.detail"
class="text-xs text-n-slate-11 break-words"
>
{{ step.detail }}
</span>
</div>
</div>
</div>
</div>
<div v-if="citations.length" class="flex flex-col gap-2">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
</span>
<span class="text-xs text-n-slate-10">
{{
t(
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
citations.length
)
}}
</span>
</div>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li
v-for="citation in citations"
:key="citation.id"
class="text-xs text-n-slate-12"
>
<a
v-if="citation.link"
:href="citation.link"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-n-blue-11 hover:underline"
>
{{ citation.title || citation.link }}
</a>
<span v-else>{{ citation.title }}</span>
</li>
</ul>
</div>
<div v-if="reasoning" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
</span>
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
{{ reasoning }}
</p>
</div>
<span v-if="devDetails" class="text-xs text-n-slate-11">
{{ devDetails }}
</span>
</template>
</div>
</template>
</Popover>
<slot name="meta" />
</div>
</template>
@@ -2,7 +2,6 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -10,38 +9,16 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const {
variant,
orientation,
inReplyTo,
shouldGroupWithNext,
id,
sender,
senderType,
} = useMessageContext();
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const { t } = useI18n();
const isCaptainMessage = computed(
() =>
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
);
const metaColorClass = computed(() =>
variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11'
);
const emailMetaClass = computed(() =>
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
);
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -137,21 +114,16 @@ const replyToPreview = computed(() => {
/>
</div>
<slot />
<template v-if="shouldShowMeta">
<CaptainGenerationDetails
v-if="isCaptainMessage"
:message-id="id"
class="mt-2"
>
<template #meta>
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
</template>
</CaptainGenerationDetails>
<MessageMeta
v-else
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
class="mt-2"
/>
</template>
<MessageMeta
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
</div>
</template>
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: conversationId.value,
});
const response = await whatsappCallSession.initiateOutboundCall(
conversationId.value
);
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
@@ -1,11 +1,7 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import {
useBreakpoints,
breakpointsTailwind,
useEventListener,
} from '@vueuse/core';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -20,10 +16,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
closeOnScroll: {
type: Boolean,
default: true,
},
showContentBorder: {
type: Boolean,
default: true,
@@ -49,12 +41,8 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
{ align: props.align }
);
const SCROLL_CLOSE_THRESHOLD = 24;
const triggerTopAtOpen = ref(0);
const show = async () => {
isActive.value = true;
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (!isMobile.value) {
await nextTick();
updatePosition();
@@ -68,22 +56,6 @@ const hide = () => {
emit('hide');
};
// The teleported popover tracks its trigger while ancestors scroll; allow
// small drift (trackpad inertia), but close once the trigger moves further.
useEventListener(
window,
'scroll',
event => {
if (!props.closeOnScroll || !showPopover.value) return;
if (popoverRef.value?.contains(event.target)) return;
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
hide();
}
},
{ capture: true, passive: true }
);
const toggle = async () => {
if (isActive.value) hide();
else await show();
@@ -13,7 +13,6 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
@@ -85,10 +84,29 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isWhatsAppComplete(props.template, processedParams.value)
);
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
const v$ = useVuelidate(
{
@@ -77,15 +77,6 @@ const handleClose = () => {
emit('close');
};
const handleFocusOut = event => {
// Keep the menu open while focus stays inside it (e.g. the label search
// input); close it once focus leaves the menu entirely.
if (menuRef.value?.contains(event.relatedTarget)) {
return;
}
handleClose();
};
onUnmounted(() => {
isLocked.value = false;
});
@@ -98,7 +89,7 @@ onUnmounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@focusout="handleFocusOut"
@blur="handleClose"
>
<slot />
</div>
@@ -8,8 +8,6 @@ import {
EditorState,
Selection,
imageResizeView,
toggleMark,
wrapInList,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: props.chat.id,
});
const response = await whatsappCallSession.initiateOutboundCall(
props.chat.id
);
// Composable returns LOCKED when init is already in flight or a call is
// active; soft no-op so a parallel click doesn't trigger a banner.
@@ -33,7 +33,9 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -93,6 +95,7 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -170,6 +173,13 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -454,7 +464,15 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -7,13 +7,10 @@ import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import { picoSearch } from '@scmmishra/pico-search';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const MENU = {
MARK_AS_READ: 'mark-as-read',
@@ -34,8 +31,6 @@ export default {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
NextInput,
Icon,
},
props: {
chatId: {
@@ -92,7 +87,6 @@ export default {
data() {
return {
MENU,
labelSearchQuery: '',
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -222,14 +216,6 @@ export default {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
filteredLabels() {
const labels = this.labelSearchQuery
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
: this.labels;
// Assigned labels first, keeping each group's existing order.
const isAssigned = label => this.conversationLabels.includes(label.title);
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -349,49 +335,21 @@ export default {
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<div class="pb-1 w-[12.5rem]">
<NextInput
v-model="labelSearchQuery"
type="search"
size="sm"
class="w-full"
custom-input-class="!ps-8 !text-xs"
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
@click.stop
@keydown.stop
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
/>
</template>
</NextInput>
</div>
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
<MenuItem
v-for="label in filteredLabels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@mousedown.prevent
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
<p
v-if="!filteredLabels.length"
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
>
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
</p>
</div>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
@@ -15,7 +15,7 @@ defineProps({
</script>
<template>
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
@@ -52,7 +52,7 @@ defineProps({
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div>
</template>
@@ -1,9 +1,9 @@
import { useMapGetter } from 'dashboard/composables/store';
import * as agentHelper from 'dashboard/helper/agentHelper';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
@@ -94,32 +94,6 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('keeps nameless agent bots and applies a fallback label', () => {
const namelessBot = {
id: 91,
name: null,
assignee_type: 'AgentBot',
availability_status: 'offline',
};
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => [
...allAgentsData,
namelessBot,
]),
});
const { agentsList } = useAgentsList();
// access the computed to trigger evaluation
expect(agentsList.value).toBeDefined();
const passedAgents =
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
expect(passedAgents).toContainEqual({
...namelessBot,
name: '-',
});
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -1,10 +1,10 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
} from 'dashboard/helper/agentHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* A composable function that provides a list of agents for assignment.
@@ -53,11 +53,7 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = (assignableAgents.value || []).map(agent =>
!agent.name && agent.assignee_type === 'AgentBot'
? { ...agent, name: '-' }
: agent
);
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -308,8 +308,7 @@ export function useWhatsappCallSession() {
}
};
// target: { conversationId } or { contactId, inboxId }
const initiateOutboundCall = async target => {
const initiateOutboundCall = async conversationId => {
// Module-scoped lock + active-session guard so a second click — from the
// same composable instance OR a different one (header vs contact panel)
// OR while a call is already live — can't tear down the in-flight setup
@@ -321,7 +320,10 @@ export function useWhatsappCallSession() {
isInitiatingOutbound.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(target, sdpOffer);
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
if (response?.id) {
activeCallId = response.id;
// A connect webhook that raced ahead of this response was buffered;
@@ -352,7 +354,7 @@ export function useWhatsappCallSession() {
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
) {
return { status: data.status, conversation_id: data.conversation_id };
return { status: data.status };
}
throw e;
} finally {
@@ -78,3 +78,5 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
+2 -1
View File
@@ -7,7 +7,8 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
+19 -2
View File
@@ -127,8 +127,25 @@ export const getHostNameFromURL = url => {
}
};
// Shared with the mobile app via @chatwoot/utils.
export { extractFilenameFromUrl } from '@chatwoot/utils';
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
/**
* Normalizes a comma/newline separated list of domains
@@ -7,7 +7,7 @@
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
.sort((a, b) => a.name.localeCompare(b.name));
};
/**
@@ -1,6 +1,4 @@
import {
InputRule,
inputRules,
MessageMarkdownSerializer,
MessageMarkdownTransformer,
messageSchema,
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -26,18 +26,6 @@ describe('agentHelper', () => {
offlineAgentsData
);
});
it('does not throw when an agent has a null name', () => {
const agents = [
{ id: 1, name: null, availability_status: 'offline' },
{ id: 2, name: 'Zoe', availability_status: 'offline' },
];
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
expect(
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
).toEqual([1, 2]);
});
});
describe('getSortedAgentsByAvailability', () => {
@@ -156,18 +156,12 @@ describe('templateHelper', () => {
]);
});
it('should handle templates with no variables but a media header', () => {
it('should handle templates with no variables', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate);
// hasMediaHeader is derived from the template, so the document header is kept.
expect(result.body).toBeUndefined();
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should build parameters for templates with multiple component types', () => {
@@ -1,16 +1,19 @@
import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
// Constants and pure template helpers are shared with the mobile app via
// @chatwoot/utils so the logic lives in one place.
export {
MEDIA_FORMATS,
COMPONENT_TYPES,
findComponentByType,
processVariable,
} from '@chatwoot/utils';
// Constants
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
@@ -24,7 +27,70 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
});
};
// The media-header flag is derived from the template inside the shared helper;
// the second argument is kept for backwards-compatible call sites.
export const buildTemplateParameters = template =>
buildWhatsAppProcessedParams(template);
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -70,20 +72,6 @@
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CAPTAIN_GENERATION": {
"TITLE": "How was this reply generated?",
"GENERATED_BY": "Generated by Captain",
"LOADING": "Loading details…",
"EMPTY": "No generation details available for this message.",
"TIMELINE": "Generation steps",
"STEP_TOOL": "Called {name}",
"STEP_HANDOFF": "Handed off to {name}",
"REASONING": "Reasoning",
"SOURCES": "Knowledge base",
"SOURCES_SUMMARY": "{count} result | {count} results",
"MODEL": "Generated with {model}",
"CREDITS": "Credits: {credits}"
},
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels",
@@ -193,8 +181,6 @@
},
"ASSIGN_AGENT": "Assign agent",
"ASSIGN_LABEL": "Assign label",
"SEARCH_LABELS": "Search labels",
"NO_LABELS_FOUND": "No labels found",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
@@ -58,7 +58,9 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -291,6 +293,100 @@
"WEBHOOK_URL": "Webhook URL",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"MANUAL_SETUP": {
"HEADER": {
"TITLE": "Connect WhatsApp manually",
"DESCRIPTION": "Follow these steps to prepare your Meta account and connect your number to Chatwoot."
},
"APP": {
"TITLE": "Create or select a Meta app",
"DESCRIPTION": "Your WhatsApp number must belong to a Meta app with the WhatsApp use case enabled.",
"ITEM_1": "Open {metaDevelopers} and sign in with an administrator account.",
"META_DEVELOPERS": "Meta Developers",
"ITEM_2": "Create a new app, or select the app you already use for this WhatsApp number.",
"ITEM_3": "Choose the option to connect with customers through WhatsApp.",
"ITEM_4": "Select the business portfolio that owns, or will own, the WhatsApp number.",
"VIDEO_TITLE": "Watch: Create a Meta app",
"VIDEO_DESCRIPTION": "This short walkthrough shows where to start a new app in Meta Developers."
},
"NUMBER": {
"TITLE": "Add your phone number and get its IDs",
"DESCRIPTION": "Add and verify the production number in your Meta app, then copy the two identifiers shown in API Setup.",
"ITEM_1": "Open the WhatsApp use case in your Meta app and choose API Setup.",
"ITEM_2": "In the Send and receive messages section, open the From selector.",
"ITEM_3": "Select an existing production number, or choose Add phone number.",
"ITEM_4": "Complete the WhatsApp business profile requested by Meta.",
"ITEM_5": "Verify the phone number using the OTP sent by SMS or voice call.",
"ITEM_6": "Copy the Phone Number ID and WhatsApp Business Account ID shown in API Setup.",
"VIDEO_TITLE": "Watch: Add a phone number and find its IDs",
"VIDEO_DESCRIPTION": "This walkthrough shows how to add or select a production number and copy the identifiers from Meta."
},
"TOKEN": {
"TITLE": "Generate a permanent access token",
"DESCRIPTION": "Create a Meta system user with access to your app and WhatsApp Business Account.",
"ITEM_1": "Open Meta Business Settings and go to Users → System users.",
"ITEM_2": "Create an admin system user, or select an existing one.",
"ITEM_3": "Assign your Meta app and WhatsApp Business Account to the system user.",
"ITEM_4": "Grant full control for the assigned WhatsApp assets.",
"ITEM_5": "Generate a token for your Meta app and set its expiration to Never.",
"ITEM_6": "Select whatsapp_business_management and whatsapp_business_messaging, then copy the token.",
"VIDEO_TITLE": "Watch: Generate a permanent access token",
"VIDEO_DESCRIPTION": "This walkthrough shows how to select your Meta app, choose a non-expiring token, and grant the required WhatsApp permissions.",
"WARNING": "Meta shows the token only once. Copy it before closing the dialog."
},
"DETAILS": {
"WABA_LABEL": "WhatsApp Business Account ID",
"WABA_PLACEHOLDER": "Enter WABA ID",
"PHONE_ID_LABEL": "Phone Number ID",
"PHONE_ID_PLACEHOLDER": "Enter Phone Number ID",
"TOKEN_LABEL": "Permanent access token",
"TOKEN_PLACEHOLDER": "Paste access token"
},
"REVIEW": {
"TITLE": "Create your WhatsApp inbox",
"DESCRIPTION": "Review the number details and choose a name for the inbox.",
"VERIFIED": "Your Meta number and access token are valid.",
"BUSINESS_NAME": "Business name",
"PHONE_NUMBER": "Phone number",
"PHONE_ID": "Phone Number ID",
"WABA_ID": "WhatsApp Business Account ID",
"INBOX_NAME": "Inbox name",
"INBOX_NAME_HELP": "We generated this name from your verified Meta business name. You can change it."
},
"VERIFY": {
"TITLE": "Verify your connection",
"DESCRIPTION": "Chatwoot is checking number access and configuring your Meta webhook.",
"NUMBER_ACCESS": "Number access",
"TEMPLATE_ACCESS": "Template access",
"CALLBACK": "Webhook callback",
"SUBSCRIPTION": "Webhook subscription",
"WEBHOOK_URL": "Webhook URL",
"COPY": "Copy",
"COPY_SUCCESS": "Webhook URL copied to clipboard",
"COMPLETE": "Verified",
"PENDING": "Pending",
"SUCCESS": "Your WhatsApp number is connected and ready for agent assignment."
},
"ACTIONS": {
"BACK": "Back",
"OPEN_META_APPS": "Open Meta Apps",
"APP_READY": "My Meta app is ready",
"NEXT": "Next",
"OPEN_BUSINESS_SETTINGS": "Open Meta Business Settings",
"SHOW_TOKEN": "Show token",
"HIDE_TOKEN": "Hide token",
"VERIFY_DETAILS": "Verify details",
"CONNECT": "Create inbox",
"RETRY_WEBHOOK": "Retry webhook setup",
"CONTINUE": "Continue to add agents"
},
"ERRORS": {
"IDS_REQUIRED": "Enter the Phone Number ID and WhatsApp Business Account ID.",
"REQUIRED": "Enter the WABA ID, Phone Number ID, and permanent access token.",
"INVALID_TOKEN": "This access token is invalid or expired. Generate a new permanent token and try again.",
"GENERIC": "We could not complete the WhatsApp setup. Check your details and try again."
}
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick setup with Meta",
@@ -1,6 +1,5 @@
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { until } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
@@ -50,9 +49,7 @@ const isVoiceEnabled = computed(
const calls = computed(() => callHistoryStore.records);
const meta = computed(() => callHistoryStore.meta);
const isFetching = computed(() => callHistoryStore.uiFlags.isFetching);
const accountUiFlags = useMapGetter('accounts/getUIFlags');
const isInitializing = ref(true);
const inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
// Filters are seeded from the URL so a shared link restores the same view.
const activity = ref(
@@ -101,25 +98,20 @@ const onPageChange = page => {
fetchCalls();
};
onMounted(async () => {
try {
await Promise.all([
store.dispatch('inboxes/get'),
until(() => accountUiFlags.value.isFetchingItem).toBe(false),
]);
if (!isVoiceEnabled.value) return;
// Only admins see the assignee filter, so only they need the agent list.
if (isAdmin.value) store.dispatch('agents/get');
await fetchCalls();
} finally {
isInitializing.value = false;
}
// inboxes/get flips isFetching true synchronously, so the spinner shows on the
// first render and the setup CTA never flashes; hit the calls endpoint only
// once inboxes confirm voice is on.
store.dispatch('inboxes/get').then(() => {
if (!isVoiceEnabled.value) return;
// Only admins see the assignee filter, so only they need the agent list.
if (isAdmin.value) store.dispatch('agents/get');
fetchCalls();
});
</script>
<template>
<div
v-if="isInitializing"
v-if="inboxesUiFlags.isFetching"
class="flex items-center justify-center w-full h-full bg-n-surface-1"
>
<Spinner :size="24" />
@@ -1,5 +1,5 @@
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -26,83 +26,21 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const metricStats = ref(null);
const faqStats = ref(null);
const isFetchingMetrics = ref(false);
const stats = ref(null);
// Increments on every fetch so a response (or retry) from a superseded
// range/assistant can't clobber the latest request's state.
let metricsFetchToken = 0;
let faqStatsFetchToken = 0;
let metricsAbortController = null;
let faqStatsAbortController = null;
const fetchMetrics = async () => {
metricsFetchToken += 1;
const token = metricsFetchToken;
metricsAbortController?.abort();
metricsAbortController = new AbortController();
const { signal } = metricsAbortController;
metricStats.value = null;
isFetchingMetrics.value = true;
const requestMetrics = () =>
CaptainAssistant.getMetrics({
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
signal,
});
let data = null;
try {
({ data } = await requestMetrics());
stats.value = data;
} catch {
// One silent retry before giving up, unless the request was aborted.
try {
if (token === metricsFetchToken && !signal.aborted)
({ data } = await requestMetrics());
} catch {
data = null;
}
}
if (token !== metricsFetchToken || signal.aborted) return;
metricStats.value = data;
isFetchingMetrics.value = false;
};
const fetchFaqStats = async () => {
faqStatsFetchToken += 1;
const token = faqStatsFetchToken;
faqStatsAbortController?.abort();
faqStatsAbortController = new AbortController();
const { signal } = faqStatsAbortController;
faqStats.value = null;
try {
const { data } = await CaptainAssistant.getFaqStats({
assistantId: assistantId.value,
signal,
});
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
} catch {
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
stats.value = null;
}
};
const summaryStats = computed(() => {
if (!metricStats.value || !faqStats.value) return null;
return { ...metricStats.value, knowledge: faqStats.value };
});
onUnmounted(() => {
metricsAbortController?.abort();
faqStatsAbortController?.abort();
});
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
watch(assistantId, fetchFaqStats, { immediate: true });
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
@@ -122,7 +60,7 @@ const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = metricStats.value?.[statKey];
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
@@ -216,9 +154,9 @@ const closeDrilldown = () => {
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="faqStats ?? undefined" />
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
<WelcomeCard :range="selectedRange" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
@@ -231,15 +169,12 @@ const closeDrilldown = () => {
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:loading="isFetchingMetrics"
:clickable="
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
"
:clickable="canDrilldown && Boolean(metric.metric)"
@click="openDrilldown(metric)"
/>
</div>
<KnowledgeCard :knowledge="faqStats ?? undefined" />
<KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks />
</div>
@@ -18,7 +18,9 @@ export function useChannelConfig() {
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) &&
isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
)) &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
@@ -135,7 +135,7 @@ onMounted(() => {
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name || ''"
:name="bot.name"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
@@ -14,7 +14,7 @@ const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = computed(() => currentAccount.value?.features || {});
const enabledFeatures = ref({});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
@@ -105,6 +105,10 @@ const channelList = computed(() => {
return channels;
});
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => {
const params = {
sub_page: channel,
@@ -112,6 +116,10 @@ const initChannelAuth = channel => {
};
router.push({ name: 'settings_inboxes_page_channel', params });
};
onMounted(() => {
initializeEnabledFeatures();
});
</script>
<template>
@@ -49,9 +49,10 @@ const hasDuplicateInstagramInbox = computed(() => {
});
const shouldShowWhatsAppWebhookDetails = computed(() => {
const source = currentInbox.value.provider_config?.source;
return (
isAWhatsAppCloudChannel.value &&
currentInbox.value.provider_config?.source !== 'embedded_signup'
!['embedded_signup', 'manual_setup_v2'].includes(source)
);
});
@@ -69,7 +69,7 @@ const items = computed(() => {
:global-config="globalConfig"
:items="items"
/>
<div class="col-span-6 flex flex-col overflow-y-auto">
<div class="col-span-6 flex min-h-0 flex-col overflow-y-auto">
<router-view />
</div>
</div>
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -80,6 +84,7 @@ export default {
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -343,6 +348,12 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -379,11 +390,6 @@ export default {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
(!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
)) &&
this.inbox.reauthorization_required
);
},
@@ -809,6 +815,29 @@ export default {
:class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
@@ -5,6 +5,7 @@ import { useI18n, I18nT } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappManualSetup from './WhatsappManualSetup.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -42,7 +43,9 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => {
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
hasWhatsappAppId.value &&
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
))
);
});
@@ -77,14 +80,21 @@ const shouldShowCloudWhatsapp = provider => {
);
};
const isManualSetup = computed(
() =>
showConfiguration.value && shouldShowCloudWhatsapp(selectedProvider.value)
);
const handleManualLinkClick = () => {
selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL);
};
</script>
<template>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div v-if="showProviderSelection">
<div class="col-span-6 w-full h-full min-h-0 overflow-y-auto p-6">
<WhatsappManualSetup v-if="isManualSetup" />
<div v-else-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
@@ -135,9 +145,6 @@ const handleManualLinkClick = () => {
</div>
</div>
<!-- Show manual setup -->
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<!-- Other providers -->
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
@@ -0,0 +1,826 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue';
import { I18nT, useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import WhatsappChannelAPI from 'dashboard/api/channel/whatsappChannel';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const TOTAL_STEPS = 5;
const POLL_INTERVAL = 2000;
const MAX_POLL_ATTEMPTS = 5;
const META_APPS_URL = 'https://developers.facebook.com/apps/';
const META_BUSINESS_SETTINGS_URL =
'https://business.facebook.com/settings/system-users/';
const CREATE_APP_VIDEO_URL =
'/videos/whatsapp/manual-setup/create-meta-app.mp4';
const CREATE_APP_VIDEO_POSTER_URL =
'/videos/whatsapp/manual-setup/create-meta-app-poster.jpg';
const ADD_NUMBER_VIDEO_URL =
'/videos/whatsapp/manual-setup/add-phone-number.mp4';
const ADD_NUMBER_VIDEO_POSTER_URL =
'/videos/whatsapp/manual-setup/add-phone-number-poster.jpg';
const GENERATE_TOKEN_VIDEO_URL =
'/videos/whatsapp/manual-setup/generate-access-token.mp4';
const GENERATE_TOKEN_VIDEO_POSTER_URL =
'/videos/whatsapp/manual-setup/generate-access-token-poster.jpg';
const currentStep = ref(1);
const isLoading = ref(false);
const errorMessage = ref('');
const inboxId = ref(null);
const pollTimer = ref(null);
const showAccessToken = ref(false);
const setupRoot = ref(null);
const form = reactive({
wabaId: '',
phoneNumberId: '',
accessToken: '',
inboxName: '',
});
const preview = ref(null);
const connection = reactive({
numberAccess: false,
templateAccess: false,
webhookSetup: false,
callbackVerified: false,
callbackConfigured: false,
callbackUrl: '',
subscriptionVerified: false,
});
const idsComplete = computed(
() => form.wabaId.trim() && form.phoneNumberId.trim()
);
const detailsComplete = computed(
() => idsComplete.value && form.accessToken.trim()
);
const connectionReady = computed(
() =>
connection.numberAccess &&
connection.templateAccess &&
connection.webhookSetup &&
connection.callbackVerified &&
connection.callbackConfigured &&
connection.subscriptionVerified
);
const progressSteps = computed(() =>
Array.from({ length: TOTAL_STEPS }, (_, index) => index + 1)
);
const appInstructions = computed(() => [
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_2'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_3'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_4'),
]);
const numberInstructions = computed(() => [
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_1'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_2'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_3'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_4'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_5'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_6'),
]);
const tokenInstructions = computed(() => [
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_1'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_2'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_3'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_4'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_5'),
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_6'),
]);
const statusRows = computed(() => [
{
key: 'number',
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.NUMBER_ACCESS'),
complete: connection.numberAccess,
},
{
key: 'templates',
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.TEMPLATE_ACCESS'),
complete: connection.templateAccess,
},
{
key: 'callback',
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.CALLBACK'),
complete:
connection.webhookSetup &&
connection.callbackConfigured &&
connection.callbackVerified,
},
{
key: 'subscription',
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.SUBSCRIPTION'),
complete: connection.subscriptionVerified,
},
]);
const apiErrorMessage = error => {
const message = error.response?.data?.message || '';
if (/invalid oauth access token|cannot parse access token/i.test(message)) {
return t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.INVALID_TOKEN');
}
return message || t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.GENERIC');
};
const setStep = step => {
errorMessage.value = '';
currentStep.value = step;
nextTick(() => setupRoot.value?.scrollIntoView({ block: 'start' }));
};
const returnToProviders = () => {
router.push({
name: route.name,
params: route.params,
query: {},
});
};
const goBack = () => {
if (currentStep.value > 1) {
setStep(currentStep.value - 1);
return;
}
returnToProviders();
};
const continueFromIds = () => {
if (!idsComplete.value) {
errorMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.IDS_REQUIRED'
);
return;
}
setStep(3);
};
const verifyDetails = async () => {
if (!detailsComplete.value) {
errorMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.REQUIRED'
);
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
const { data } = await WhatsappChannelAPI.previewManualSetup({
waba_id: form.wabaId.trim(),
phone_number_id: form.phoneNumberId.trim(),
access_token: form.accessToken.trim(),
});
preview.value = data;
form.inboxName = data.suggested_inbox_name;
setStep(4);
} catch (error) {
errorMessage.value = apiErrorMessage(error);
} finally {
isLoading.value = false;
}
};
const applyWebhookStatus = status => {
connection.callbackVerified = Boolean(status.callback_verified);
connection.callbackConfigured = Boolean(status.callback_configured);
connection.callbackUrl = status.callback_url || '';
connection.subscriptionVerified = Boolean(status.subscription_verified);
};
const copyWebhookUrl = async () => {
await copyTextToClipboard(connection.callbackUrl);
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.COPY_SUCCESS'));
};
const refreshWebhookStatus = async ({ showError = true } = {}) => {
if (!inboxId.value) return;
try {
const { data } = await WhatsappChannelAPI.getManualWebhookStatus(
inboxId.value
);
applyWebhookStatus(data);
} catch (error) {
if (showError) errorMessage.value = apiErrorMessage(error);
}
};
const pollWebhookStatus = async attempt => {
await refreshWebhookStatus({ showError: false });
if (connectionReady.value || attempt >= MAX_POLL_ATTEMPTS) return;
pollTimer.value = window.setTimeout(
() => pollWebhookStatus(attempt + 1),
POLL_INTERVAL
);
};
const connectNumber = async () => {
isLoading.value = true;
errorMessage.value = '';
try {
const { data } = await WhatsappChannelAPI.connectManualSetup({
waba_id: form.wabaId.trim(),
phone_number_id: form.phoneNumberId.trim(),
access_token: form.accessToken.trim(),
inbox_name: form.inboxName.trim(),
});
inboxId.value = data.id;
connection.numberAccess = Boolean(data.number_access);
connection.templateAccess = Boolean(data.template_access);
connection.webhookSetup = Boolean(data.webhook_setup);
if (data.webhook_error) errorMessage.value = data.webhook_error;
currentStep.value = 5;
await pollWebhookStatus(1);
} catch (error) {
errorMessage.value = apiErrorMessage(error);
} finally {
isLoading.value = false;
}
};
const retryWebhookSetup = async () => {
isLoading.value = true;
errorMessage.value = '';
try {
const { data } = await WhatsappChannelAPI.setupManualWebhook(inboxId.value);
connection.webhookSetup = true;
applyWebhookStatus(data);
if (!connectionReady.value) await pollWebhookStatus(1);
} catch (error) {
errorMessage.value = apiErrorMessage(error);
} finally {
isLoading.value = false;
}
};
const continueToAgents = async () => {
await store.dispatch('inboxes/get');
router.replace({
name: 'settings_inboxes_add_agents',
params: { page: 'new', inbox_id: inboxId.value },
});
};
onBeforeUnmount(() => {
if (pollTimer.value) window.clearTimeout(pollTimer.value);
});
</script>
<template>
<div
ref="setupRoot"
class="mx-auto flex w-full max-w-6xl flex-col gap-4 py-2"
>
<div class="px-1">
<div class="min-w-0 flex-1">
<h1 class="text-heading-1 text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.HEADER.TITLE') }}
</h1>
<p class="mt-1 text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.HEADER.DESCRIPTION') }}
</p>
</div>
</div>
<div
class="rounded-2xl border border-n-weak bg-n-background p-5 shadow-sm sm:p-6"
>
<div class="flex justify-end">
<div class="grid w-40 grid-cols-5 gap-1.5">
<div
v-for="step in progressSteps"
:key="step"
class="h-1.5 rounded-full"
:class="step <= currentStep ? 'bg-n-brand' : 'bg-n-alpha-3'"
/>
</div>
</div>
<div
v-if="errorMessage && currentStep !== 3"
class="mt-6 rounded-lg border border-n-ruby-5 bg-n-ruby-3 px-4 py-3 text-sm text-n-ruby-11"
>
{{ errorMessage }}
</div>
<section v-if="currentStep === 1" class="mt-3 grid gap-5 lg:grid-cols-2">
<div class="lg:col-span-2">
<h2 class="text-heading-2 text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.TITLE') }}
</h2>
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.DESCRIPTION') }}
</p>
</div>
<ol
class="ml-5 list-decimal space-y-3 text-body-main text-n-slate-11 marker:font-medium marker:text-n-slate-12"
>
<li class="pl-2">
<I18nT
keypath="INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_1"
tag="span"
>
<template #metaDevelopers>
<a
:href="META_APPS_URL"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-n-brand hover:underline"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.META_DEVELOPERS'
)
}}
</a>
</template>
</I18nT>
</li>
<li
v-for="instruction in appInstructions"
:key="instruction"
class="pl-2"
>
{{ instruction }}
</li>
</ol>
<figure
class="self-start overflow-hidden rounded-xl border border-n-weak"
>
<video
class="block w-full bg-n-solid-1"
controls
muted
playsinline
preload="metadata"
:poster="CREATE_APP_VIDEO_POSTER_URL"
:aria-label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.VIDEO_TITLE')
"
>
<source :src="CREATE_APP_VIDEO_URL" type="video/mp4" />
</video>
<figcaption class="border-t border-n-weak bg-n-alpha-1 px-4 py-3">
<p class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.VIDEO_TITLE') }}
</p>
</figcaption>
</figure>
<div
class="flex items-center justify-between border-t border-n-weak pt-5 lg:col-span-2"
>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
variant="outline"
color="slate"
@click="goBack"
/>
<Button
:label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.APP_READY')
"
trailing-icon
icon="i-lucide-arrow-right"
@click="setStep(2)"
/>
</div>
</section>
<section
v-else-if="currentStep === 2"
class="mt-3 grid gap-5 lg:grid-cols-2"
>
<div class="lg:col-span-2">
<h2 class="text-heading-2 text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.TITLE') }}
</h2>
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.DESCRIPTION') }}
</p>
</div>
<ol
class="ml-5 list-decimal space-y-3 text-body-main text-n-slate-11 marker:font-medium marker:text-n-slate-12"
>
<li
v-for="instruction in numberInstructions"
:key="instruction"
class="pl-2"
>
{{ instruction }}
</li>
</ol>
<div class="flex min-w-0 flex-col gap-4">
<figure class="overflow-hidden rounded-xl border border-n-weak">
<video
class="block w-full bg-n-solid-1"
controls
muted
playsinline
preload="metadata"
:poster="ADD_NUMBER_VIDEO_POSTER_URL"
:aria-label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.VIDEO_TITLE')
"
>
<source :src="ADD_NUMBER_VIDEO_URL" type="video/mp4" />
</video>
<figcaption class="border-t border-n-weak bg-n-alpha-1 px-4 py-3">
<p class="text-sm font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.VIDEO_TITLE')
}}
</p>
</figcaption>
</figure>
<a
:href="META_APPS_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex w-fit items-center gap-2 text-sm font-medium text-n-brand hover:underline"
>
{{
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.OPEN_META_APPS')
}}
<Icon icon="i-lucide-external-link" />
</a>
</div>
<div
class="grid gap-5 rounded-xl border border-n-weak p-5 lg:col-span-2 lg:grid-cols-2"
>
<Input
v-model="form.phoneNumberId"
:label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.PHONE_ID_LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.PHONE_ID_PLACEHOLDER'
)
"
/>
<Input
v-model="form.wabaId"
:label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.WABA_LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.WABA_PLACEHOLDER'
)
"
/>
</div>
<div
class="flex items-center justify-between border-t border-n-weak pt-5 lg:col-span-2"
>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
variant="outline"
color="slate"
@click="goBack"
/>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.NEXT')"
trailing-icon
icon="i-lucide-arrow-right"
@click="continueFromIds"
/>
</div>
</section>
<section
v-else-if="currentStep === 3"
class="mt-3 grid gap-5 lg:grid-cols-2"
>
<div class="lg:col-span-2">
<h2 class="text-heading-2 text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.TITLE') }}
</h2>
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.DESCRIPTION') }}
</p>
</div>
<ol
class="ml-5 list-decimal space-y-3 text-body-main text-n-slate-11 marker:font-medium marker:text-n-slate-12"
>
<li
v-for="instruction in tokenInstructions"
:key="instruction"
class="pl-2"
>
{{ instruction }}
</li>
</ol>
<div class="flex min-w-0 flex-col gap-4">
<figure class="overflow-hidden rounded-xl border border-n-weak">
<video
class="block w-full bg-n-solid-1"
controls
muted
playsinline
preload="metadata"
:poster="GENERATE_TOKEN_VIDEO_POSTER_URL"
:aria-label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.VIDEO_TITLE')
"
>
<source :src="GENERATE_TOKEN_VIDEO_URL" type="video/mp4" />
</video>
<figcaption class="border-t border-n-weak bg-n-alpha-1 px-4 py-3">
<p class="text-sm font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.VIDEO_TITLE')
}}
</p>
</figcaption>
</figure>
<a
:href="META_BUSINESS_SETTINGS_URL"
target="_blank"
rel="noopener noreferrer"
class="inline-flex w-fit items-center gap-2 text-sm font-medium text-n-brand hover:underline"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.OPEN_BUSINESS_SETTINGS'
)
}}
<Icon icon="i-lucide-external-link" />
</a>
</div>
<div class="rounded-xl border border-n-weak p-5 lg:col-span-2">
<div class="grid items-start gap-3 sm:grid-cols-[1fr_auto]">
<Input
v-model="form.accessToken"
:type="showAccessToken ? 'text' : 'password'"
autocomplete="off"
:label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.TOKEN_LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.TOKEN_PLACEHOLDER'
)
"
:message="errorMessage"
message-type="error"
@update:model-value="errorMessage = ''"
/>
<Button
:label="
showAccessToken
? $t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.HIDE_TOKEN'
)
: $t(
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.SHOW_TOKEN'
)
"
variant="outline"
color="slate"
class="sm:mt-7"
@click="showAccessToken = !showAccessToken"
/>
</div>
<p
v-if="!errorMessage"
class="mt-3 flex items-start gap-2 text-sm text-n-amber-11"
>
<Icon icon="i-lucide-triangle-alert" class="mt-0.5 shrink-0" />
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.WARNING') }}
</p>
</div>
<div
class="flex items-center justify-between border-t border-n-weak pt-5 lg:col-span-2"
>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
variant="outline"
color="slate"
@click="goBack"
/>
<Button
:label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.VERIFY_DETAILS')
"
:is-loading="isLoading"
:disabled="isLoading"
@click="verifyDetails"
/>
</div>
</section>
<section v-else-if="currentStep === 4" class="mt-3 flex flex-col gap-5">
<div>
<h2 class="text-heading-2 text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.TITLE') }}
</h2>
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.DESCRIPTION') }}
</p>
</div>
<div
class="rounded-xl border border-n-teal-5 bg-n-teal-3 px-4 py-3 text-sm text-n-teal-11"
>
<div class="flex items-center gap-2 font-medium">
<Icon icon="i-lucide-badge-check" />
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.VERIFIED') }}
</div>
</div>
<dl class="divide-y divide-n-weak rounded-xl border border-n-weak px-5">
<div class="grid grid-cols-2 gap-4 py-4">
<dt class="text-sm text-n-slate-11">
{{
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.BUSINESS_NAME')
}}
</dt>
<dd class="text-sm font-medium text-n-slate-12">
{{ preview.verified_name || preview.display_phone_number }}
</dd>
</div>
<div class="grid grid-cols-2 gap-4 py-4">
<dt class="text-sm text-n-slate-11">
{{
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.PHONE_NUMBER')
}}
</dt>
<dd class="text-sm font-medium text-n-slate-12">
{{ preview.display_phone_number }}
</dd>
</div>
<div class="grid grid-cols-2 gap-4 py-4">
<dt class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.PHONE_ID') }}
</dt>
<dd class="text-sm font-medium text-n-slate-12">
{{ preview.phone_number_id }}
</dd>
</div>
<div class="grid grid-cols-2 gap-4 py-4">
<dt class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.WABA_ID') }}
</dt>
<dd class="text-sm font-medium text-n-slate-12">
{{ preview.waba_id }}
</dd>
</div>
</dl>
<Input
v-model="form.inboxName"
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.INBOX_NAME')"
:message="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.INBOX_NAME_HELP')
"
/>
<div
class="flex items-center justify-between border-t border-n-weak pt-5"
>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
variant="outline"
color="slate"
@click="goBack"
/>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.CONNECT')"
:is-loading="isLoading"
:disabled="isLoading || !form.inboxName.trim()"
@click="connectNumber"
/>
</div>
</section>
<section v-else class="mt-3 flex flex-col gap-5">
<div>
<h2 class="text-heading-2 text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.TITLE') }}
</h2>
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.DESCRIPTION') }}
</p>
</div>
<div
v-if="connection.callbackUrl"
class="rounded-xl border border-n-weak p-5"
>
<p class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.WEBHOOK_URL') }}
</p>
<div class="mt-3 flex items-center gap-3">
<code
class="min-w-0 flex-1 break-all rounded-lg bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12"
>
{{ connection.callbackUrl }}
</code>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.COPY')"
icon="i-lucide-copy"
variant="outline"
color="slate"
@click="copyWebhookUrl"
/>
</div>
</div>
<div
class="flex flex-col divide-y divide-n-weak rounded-xl border border-n-weak px-5"
>
<div
v-for="status in statusRows"
:key="status.key"
class="flex items-center justify-between py-4"
>
<span class="text-sm font-medium text-n-slate-12">
{{ status.label }}
</span>
<span
class="flex items-center gap-2 text-sm"
:class="status.complete ? 'text-n-teal-11' : 'text-n-amber-11'"
>
<Icon
:icon="
status.complete ? 'i-lucide-check-circle' : 'i-lucide-clock-3'
"
/>
{{
status.complete
? $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.COMPLETE')
: $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.PENDING')
}}
</span>
</div>
</div>
<p
v-if="connectionReady"
class="rounded-lg bg-n-teal-3 px-4 py-3 text-sm text-n-teal-11"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.SUCCESS') }}
</p>
<div
class="flex items-center justify-between border-t border-n-weak pt-5"
>
<Button
:label="
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.RETRY_WEBHOOK')
"
variant="outline"
color="slate"
:is-loading="isLoading"
:disabled="isLoading"
@click="retryWebhookSetup"
/>
<Button
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.CONTINUE')"
trailing-icon
icon="i-lucide-arrow-right"
:disabled="isLoading"
@click="continueToAgents"
/>
</div>
</section>
</div>
</div>
</template>
@@ -3,7 +3,6 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import { useBranding } from 'shared/composables/useBranding';
const props = defineProps({
senderNameType: {
@@ -23,7 +22,6 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const senderNameKeyOptions = computed(() => [
{
@@ -32,7 +30,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
businessName: replaceInstallationName('Chatwoot'),
businessName: 'Chatwoot',
email: '<support@yourbusiness.com>',
},
},
@@ -42,7 +40,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
businessName: replaceInstallationName('Chatwoot'),
businessName: 'Chatwoot',
email: '<support@yourbusiness.com>',
},
},
@@ -53,7 +51,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
: props.businessName || keyOption.preview.businessName;
: keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
@@ -56,7 +56,6 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
@@ -66,9 +65,7 @@ export default {
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
this.isOnChatwootCloud
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},
@@ -1,62 +0,0 @@
import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions';
import camelcaseKeys from 'camelcase-keys';
const SET_SESSION = 'SET_SESSION';
const SET_FETCHING = 'SET_FETCHING';
// Session capture runs right after the message is broadcast (and well after,
// for handoff notes created mid-run), so a 404 on a fresh message may just
// mean the session isn't written yet. Skip caching those so a later
// hover/click retries; older misses are permanent (V1 messages, failed runs).
const RECENT_MESSAGE_WINDOW_SECONDS = 60;
// Caches Captain agent-session metadata per message id. A missing session
// (404) is cached as null so the UI shows an empty state without refetching.
export default {
namespaced: true,
state: {
sessions: {},
fetchingIds: [],
},
getters: {
getSessionByMessageId: state => messageId => state.sessions[messageId],
isFetching: state => messageId => state.fetchingIds.includes(messageId),
hasFetched: state => messageId => messageId in state.sessions,
},
actions: {
fetch: async ({ state, commit }, { messageId, createdAt }) => {
if (messageId in state.sessions) return;
if (state.fetchingIds.includes(messageId)) return;
commit(SET_FETCHING, { messageId, isFetching: true });
try {
const { data } = await CaptainAgentSessionsAPI.show(messageId);
commit(SET_SESSION, {
messageId,
session: camelcaseKeys(data, { deep: true }),
});
} catch (error) {
const isRecentMessage =
createdAt &&
Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS;
// Only a 404 means "no session exists"; transient failures (5xx,
// network errors) stay uncached so a later hover retries.
if (error.response?.status === 404 && !isRecentMessage) {
commit(SET_SESSION, { messageId, session: null });
}
} finally {
commit(SET_FETCHING, { messageId, isFetching: false });
}
},
},
mutations: {
[SET_SESSION](state, { messageId, session }) {
state.sessions = { ...state.sessions, [messageId]: session };
},
[SET_FETCHING](state, { messageId, isFetching }) {
state.fetchingIds = isFetching
? [...state.fetchingIds, messageId]
: state.fetchingIds.filter(id => id !== messageId);
},
},
};
-2
View File
@@ -50,7 +50,6 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import captainAgentSessions from './captain/agentSessions';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
@@ -116,7 +115,6 @@ export default createStore({
teams,
userNotificationSettings,
webhooks,
captainAgentSessions,
captainAssistants,
captainDocuments,
captainResponses,
@@ -7,7 +7,6 @@ import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
import { isSendableTemplate } from '@chatwoot/utils';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -68,8 +67,45 @@ export const getters = {
return [];
}
// Sendable-template filtering is shared with the mobile app via @chatwoot/utils.
return templates.filter(isSendableTemplate);
return templates.filter(template => {
// Ensure template has required properties
if (!template || !template.status || !template.components) {
return false;
}
// Only show approved templates
if (template.status.toLowerCase() !== 'approved') {
return false;
}
// Filter out authentication templates
if (template.category === 'AUTHENTICATION') {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
component.type
) ||
(component.type === 'HEADER' && component.format === 'LOCATION')
);
if (hasUnsupportedComponents) {
return false;
}
return true;
});
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
@@ -68,10 +68,6 @@ const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
const selectedItemName = computed(() =>
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
);
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
@@ -99,16 +95,16 @@ const selectedThumbnail = computed(
<h4
v-else
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
:title="selectedItemName"
:title="selectedItem.name"
>
{{ selectedItemName }}
{{ selectedItem.name }}
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
:status="selectedItem.availability_status"
:name="selectedItemName"
:name="selectedItem.name"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24"
hide-offline-status
@@ -53,9 +53,7 @@ export default {
computed: {
filteredOptions() {
return this.options.filter(option => {
return (option.name || '')
.toLowerCase()
.includes(this.search.toLowerCase());
return option.name.toLowerCase().includes(this.search.toLowerCase());
});
},
noResult() {
@@ -73,13 +73,13 @@ describe('useBranding', () => {
expect(result).toBe('Welcome to our platform');
});
it('should replace "Chatwoot" regardless of casing', () => {
it('should be case-sensitive for "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Welcome to chatwoot, Chatwoot and CHATWOOT'
'Welcome to chatwoot and CHATWOOT'
);
expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany');
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
});
it('should handle special characters in installation name', () => {
@@ -7,8 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
* Replaces "Chatwoot" (any casing) in text with the installation name from
* global config
* Replaces "Chatwoot" in text with the installation name from global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
@@ -18,7 +17,7 @@ export function useBranding() {
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
return text.replace(/chatwoot/gi, installationName);
return text.replace(/Chatwoot/g, installationName);
};
return {
+12 -37
View File
@@ -11,8 +11,6 @@ import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
const TRANSCRIPT_COOLDOWN_MS = 15000;
export default {
components: {
ChatInputWrap,
@@ -26,9 +24,6 @@ export default {
data() {
return {
inReplyTo: null,
isSendingTranscript: false,
transcriptCooldown: false,
transcriptCooldownTimer: null,
};
},
computed: {
@@ -62,9 +57,6 @@ export default {
mounted() {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
beforeUnmount() {
clearTimeout(this.transcriptCooldownTimer);
},
methods: {
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversationAttributes', ['getAttributes']),
@@ -98,35 +90,19 @@ export default {
toggleReplyTo(message) {
this.inReplyTo = message;
},
startTranscriptCooldown() {
this.transcriptCooldown = true;
clearTimeout(this.transcriptCooldownTimer);
this.transcriptCooldownTimer = setTimeout(() => {
this.transcriptCooldown = false;
}, TRANSCRIPT_COOLDOWN_MS);
},
async sendTranscript() {
if (
!this.hasEmail ||
this.isSendingTranscript ||
this.transcriptCooldown
) {
return;
}
this.isSendingTranscript = true;
try {
await sendEmailTranscript();
this.startTranscriptCooldown();
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
type: 'success',
});
} catch (error) {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
});
} finally {
this.isSendingTranscript = false;
if (this.hasEmail) {
try {
await sendEmailTranscript();
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
type: 'success',
});
} catch (error) {
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
});
}
}
},
},
@@ -168,7 +144,6 @@ export default {
v-if="showEmailTranscriptButton"
type="clear"
class="font-normal"
:disabled="isSendingTranscript || transcriptCooldown"
@click="sendTranscript"
>
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
+5 -5
View File
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end
def get_channel_from_wb_payload(wb_params)
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
# validate to ensure the phone number id matches the whatsapp channel
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
+4 -3
View File
@@ -160,8 +160,9 @@ class Channel::Whatsapp < ApplicationRecord
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'
# Embedded signup and Manual V2 run webhook setup explicitly so their API
# responses can reflect the real result instead of swallowing callback errors.
explicitly_configured_sources = %w[embedded_signup manual_setup_v2]
provider == 'whatsapp_cloud' && explicitly_configured_sources.exclude?(provider_config['source'])
end
end
@@ -30,6 +30,54 @@ class Whatsapp::FacebookApiClient
handle_response(response, 'WABA phone numbers fetch failed')
end
def fetch_all_phone_numbers(waba_id)
phone_numbers = []
after_cursor = nil
loop do
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
headers: request_headers,
query: after_cursor.present? ? { after: after_cursor } : {}
)
data = handle_response(response, 'WABA phone numbers fetch failed')
phone_numbers.concat(data['data'] || [])
after_cursor = data.dig('paging', 'cursors', 'after') if data.dig('paging', 'next').present?
break if after_cursor.blank?
end
phone_numbers
end
def fetch_message_templates(waba_id)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{waba_id}/message_templates",
headers: request_headers,
query: { limit: 1 }
)
handle_response(response, 'WABA message templates fetch failed')
end
def fetch_subscribed_apps(waba_id)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers
)
handle_response(response, 'WABA webhook subscription fetch failed')
end
def fetch_phone_number(phone_number_id, fields: nil)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
headers: request_headers,
query: fields.present? ? { fields: fields } : {}
)
handle_response(response, 'Phone number fetch failed')
end
def debug_token(input_token)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/debug_token",
@@ -113,14 +113,12 @@ class Whatsapp::IncomingMessageBaseService
end
def set_conversation
# Scope reuse to the contact across all its contact_inboxes in this inbox: WhatsApp coexistence
# gives one contact multiple source_ids (phone + BSUID), so reopen must not be limited to a single contact_inbox.
conversations = @contact.conversations.where(inbox_id: @inbox.id)
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
@conversation = if @inbox.lock_to_single_conversation
conversations.last
@contact_inbox.conversations.last
else
conversations.where.not(status: :resolved).last
@contact_inbox.conversations
.where.not(status: :resolved).last
end
return if @conversation
@@ -0,0 +1,57 @@
class Whatsapp::ManualSetupService
attr_reader :channel, :webhook_error
def initialize(account:, waba_id:, phone_number_id:, access_token:, inbox_name: nil)
@account = account
@waba_id = waba_id
@phone_number_id = phone_number_id
@access_token = access_token
@inbox_name = inbox_name
end
def perform
preview = validate_setup
create_channel_and_inbox(preview)
setup_webhook
self
end
def webhook_setup?
webhook_error.blank?
end
private
def validate_setup
Whatsapp::ManualSetupValidationService.new(
waba_id: @waba_id,
phone_number_id: @phone_number_id,
access_token: @access_token
).perform
end
def create_channel_and_inbox(preview)
ActiveRecord::Base.transaction do
@channel = @account.whatsapp_channels.create!(
phone_number: preview[:display_phone_number],
provider: 'whatsapp_cloud',
provider_config: {
api_key: @access_token,
phone_number_id: preview[:phone_number_id],
business_account_id: preview[:waba_id],
source: 'manual_setup_v2'
}
)
@account.inboxes.create!(
name: @inbox_name.to_s.strip.presence || preview[:suggested_inbox_name],
channel: @channel
)
end
end
def setup_webhook
Whatsapp::WebhookSetupService.new(@channel, @waba_id, @access_token).register_callback
rescue StandardError => e
@webhook_error = e.message
end
end
@@ -0,0 +1,79 @@
class Whatsapp::ManualSetupValidationService
LOG_PREFIX = '[WHATSAPP MANUAL SETUP]'.freeze
def initialize(waba_id:, phone_number_id:, access_token:)
@waba_id = waba_id
@phone_number_id = phone_number_id
@access_token = access_token
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
Rails.logger.info "#{LOG_PREFIX} Validation started waba_id=#{@waba_id} phone_number_id=#{@phone_number_id}"
phone_data = find_phone_data!
verify_uniqueness!(phone_data)
verify_template_access!
build_preview(phone_data)
end
private
def validate_parameters!
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Phone Number ID is required' if @phone_number_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def find_phone_data!
phone_numbers = @api_client.fetch_all_phone_numbers(@waba_id)
returned_phone_number_ids = phone_numbers.filter_map { |phone| phone['id'] }.join(',')
Rails.logger.info "#{LOG_PREFIX} Meta returned #{phone_numbers.size} phone number(s) for waba_id=#{@waba_id} " \
"phone_number_ids=#{returned_phone_number_ids}"
phone_data = phone_numbers.find { |phone| phone['id'].to_s == @phone_number_id.to_s }
raise ArgumentError, 'This Phone Number ID does not belong to the WABA ID you entered.' if phone_data.blank?
Rails.logger.info "#{LOG_PREFIX} Matched phone_number_id=#{@phone_number_id} fields=#{phone_data.keys.sort.join(',')} " \
"code_verification_status=#{phone_data['code_verification_status'].inspect} " \
"name_status=#{phone_data['name_status'].inspect}"
phone_data
end
def verify_uniqueness!(phone_data)
phone_number = normalized_phone_number(phone_data['display_phone_number'])
raise ArgumentError, 'This WhatsApp number is already connected to another inbox.' if Channel::Whatsapp.exists?(phone_number: phone_number)
duplicate_phone_id = Channel::Whatsapp.exists?(["provider_config->>'phone_number_id' = ?", @phone_number_id.to_s])
raise ArgumentError, 'This Phone Number ID is already used by another WhatsApp inbox.' if duplicate_phone_id
end
def verify_template_access!
@api_client.fetch_message_templates(@waba_id)
rescue StandardError
raise ArgumentError,
'The token can access the number but cannot access message templates. Generate a token with whatsapp_business_management permission.'
end
def build_preview(phone_data)
phone_number = normalized_phone_number(phone_data['display_phone_number'])
verified_name = phone_data['verified_name'].presence
{
verified_name: verified_name,
display_phone_number: phone_number,
phone_number_id: phone_data['id'].to_s,
waba_id: @waba_id.to_s,
template_access: true,
suggested_inbox_name: "#{verified_name || phone_number} WhatsApp"
}
end
def normalized_phone_number(phone_number)
digits = phone_number.to_s.gsub(/[^\d]/, '')
"+#{digits}"
end
end
@@ -0,0 +1,39 @@
class Whatsapp::ManualWebhookStatusService
def initialize(channel)
@channel = channel
@api_client = Whatsapp::FacebookApiClient.new(channel.provider_config['api_key'])
end
def perform
callback_configured = callback_configured?
{
callback_verified: callback_configured,
callback_configured: callback_configured,
callback_url: callback_url,
subscription_verified: subscription_verified?
}
end
private
def callback_configured?
phone_number = @api_client.fetch_phone_number(
@channel.provider_config['phone_number_id'],
fields: 'webhook_configuration'
)
webhook_configuration = phone_number.fetch('webhook_configuration', {})
%w[override_callback_uri phone_number whatsapp_business_account application].any? do |key|
webhook_configuration[key] == callback_url
end
end
def subscription_verified?
@api_client.fetch_subscribed_apps(@channel.provider_config['business_account_id']).fetch('data', []).present?
end
def callback_url
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{@channel.phone_number}"
end
end
@@ -6,10 +6,12 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def perform_reply
return send_template_message if template_params.present?
return send_session_message if message.conversation.can_reply?
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
should_send_template_message = template_params.present? || !message.conversation.can_reply?
if should_send_template_message
send_template_message
else
send_session_message
end
end
def send_template_message
@@ -1,35 +0,0 @@
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
# raw digits first and then a normalized fallback, accepting only a candidate whose
# phone_number_id matches.
class Whatsapp::WebhookChannelFinderService
def initialize(display_phone_number:, phone_number_id:)
@display_phone_number = display_phone_number
@phone_number_id = phone_number_id
end
def perform
return if digits.blank?
candidates = [
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
channel_by_normalized_number
]
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
end
private
def digits
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
end
def channel_by_normalized_number
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
return unless normalizer
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.16.1'
version: '4.16.0'
development:
<<: *shared
+1 -1
View File
@@ -265,6 +265,6 @@
enabled: false
column: feature_flags_ext_1
- name: whatsapp_embedded_signup_inbox_creation
display_name: WhatsApp Embedded Signup Flow
display_name: WhatsApp Embedded Signup Inbox Creation
enabled: false
column: feature_flags_ext_1
+3 -27
View File
@@ -31,11 +31,10 @@ class Rack::Attack
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
end
# Rails allows paths with extensions and trailing slashes, so compare against a normalized path.
# For example, /auth, /auth.json, and /auth/ should all use the same throttle.
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
# example /auth & /auth.json would both work
def path_without_extensions
normalized_path = path[/^[^.]+/]
normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '')
path[/^[^.]+/]
end
end
@@ -189,11 +188,6 @@ class Rack::Attack
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
## Prevent Transcript Bombing on Widget API ###
throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
end
end
##-----------------------------------------------##
@@ -218,24 +212,6 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent create APIs (per account, covers bulk_create)
throttle('/api/v1/accounts/:account_id/agents POST',
limit: ENV.fetch('RATE_LIMIT_AGENT_CREATE', '100').to_i, period: 1.day) do |req|
next unless req.post?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents(?:/bulk_create)?/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent delete API (per account)
throttle('/api/v1/accounts/:account_id/agents/:id DELETE',
limit: ENV.fetch('RATE_LIMIT_AGENT_DELETE', '50').to_i, period: 1.day) do |req|
next unless req.delete?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents/(?<id>\d+)/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent Abuse of attachment upload APIs ##
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
-14
View File
@@ -143,20 +143,6 @@ features:
gemini-3-pro,
]
default: gpt-5.2
conversation_faq_matching:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
pdf_faq_generation:
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
default: gpt-4.1-mini
-2
View File
@@ -154,7 +154,6 @@ en:
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
@@ -626,7 +625,6 @@ en:
label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation'
conversation_faq_generation: 'Conversation FAQ generation'
conversation_faq_matching: 'Conversation FAQ matching'
help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation'
+5 -7
View File
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
resources :assistants do
member do
post :playground
get :metrics
get :faq_stats
get :stats
get :summary
get :drilldown
end
@@ -77,12 +76,7 @@ Rails.application.routes.draw do
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
resources :scenarios
end
resources :agent_sessions, only: [:show]
resources :assistant_responses
resources :faq_suggestions, only: [:index, :show, :update] do
post :approve, on: :member
post :dismiss, on: :member
end
resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index, :create] do
@@ -363,6 +357,10 @@ Rails.application.routes.draw do
namespace :whatsapp do
resource :authorization, only: [:create]
post 'manual/preview', to: 'manual_setup#preview'
post 'manual/connect', to: 'manual_setup#connect'
get 'manual/:inbox_id/webhook_status', to: 'manual_setup#webhook_status'
post 'manual/:inbox_id/setup_webhook', to: 'manual_setup#setup_webhook'
end
resources :webhooks, only: [:index, :create, :update, :destroy]
@@ -37,23 +37,6 @@ class Captain::AssistantStatsBuilder
build_metrics(current, previous)
end
# Approved/pending FAQ counts and the document total in a single round trip.
def faq_stats
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
end
private
attr_reader :window
@@ -73,7 +56,8 @@ class Captain::AssistantStatsBuilder
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
knowledge: knowledge
}
end
@@ -89,7 +73,7 @@ class Captain::AssistantStatsBuilder
auto_resolution: rate(resolution[:resolved], handled),
handoff: rate(resolution[:handoff], handled),
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
reopen: reopen_rate(range, resolution[:resolved]),
reopen: reopen_rate(range),
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
}
end
@@ -174,9 +158,7 @@ class Captain::AssistantStatsBuilder
# derived from the assistant's handled conversations (not current inbox membership) so a later
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
def reopen_rate(range, resolved_count)
return 0 if resolved_count.zero?
def reopen_rate(range)
resolved_scope = account.reporting_events
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
conversation_id: handled_scope(range).select(:conversation_id))
@@ -196,7 +178,24 @@ class Captain::AssistantStatsBuilder
'ON resolves.conversation_id = reporting_events.conversation_id ' \
'AND reporting_events.event_end_time >= resolves.event_end_time')
.distinct.count('reporting_events.conversation_id')
rate(reopened, resolved_count)
rate(reopened, resolved_scope.distinct.count(:conversation_id))
end
# Approved/pending FAQ counts and the document total in a single round trip.
def knowledge
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
end
def rate(numerator, denominator)
@@ -1,25 +0,0 @@
class Api::V1::Accounts::Captain::AgentSessionsController < Api::V1::Accounts::BaseController
before_action :set_message
before_action :authorize_conversation
def show
@agent_session = Current.account.captain_agent_sessions.find_by(result_type: 'Message', result_id: @message.id)
return head :not_found if @agent_session.blank?
@citations = Current.account.captain_assistant_responses
.where(id: @agent_session.faq_ids)
.includes(:documentable)
@scenario_titles = Captain::Scenario.where(account_id: Current.account.id, id: @agent_session.scenario_ids)
.pluck(:id, :title).to_h
end
private
def set_message
@message = Current.account.messages.find(params[:id])
end
def authorize_conversation
authorize @message.conversation, :show?
end
end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,7 +1,8 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
def index
@assistants = account_assistants.ordered
@@ -42,17 +43,12 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools
end
def metrics
def stats
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
end
def faq_stats
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
end
def summary
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
result = cached_or_generated_summary(window, summary_stats)
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
if result[:error]
render json: { error: result[:error] }, status: :unprocessable_content
@@ -73,8 +69,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
params.permit(:metric, :range, :timezone_offset, :page, :per_page)
end
def cached_or_generated_summary(window, stats)
cache_key = summary_cache_key(window.range)
def cached_or_generated_summary(builder)
cache_key = summary_cache_key(builder.range)
cached = Rails.cache.read(cache_key)
return cached if cached
@@ -82,25 +78,14 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
account: Current.account,
assistant: @assistant,
first_name: Current.user.name.to_s.split.first,
stats: stats,
period: window.period
stats: builder.metrics,
period: builder.period
).perform
# Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour.
Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
result
end
def summary_stats
params.require(:stats).permit(
conversations_handled: %i[current],
hours_saved: %i[current],
auto_resolution_rate: %i[current trend],
handoff_rate: %i[current trend],
reopen_rate: %i[current trend],
knowledge: %i[coverage approved documents]
).to_h.deep_symbolize_keys
end
def summary_cache_key(range)
"captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :ensure_custom_tools_enabled
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,80 +0,0 @@
class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::FaqSuggestion) }
before_action :set_accessible_suggestions
before_action :set_suggestion, except: [:index]
RESULTS_PER_PAGE = 25
SOURCE_PREVIEW_LIMIT = 50
def index
@current_page = permitted_params[:page] || 1
filtered_query = apply_filters(@suggestions)
@suggestions_count = filtered_query.count
@suggestions = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show
@observations = @suggestion.observations
.where(conversation_id: accessible_conversations.select(:id))
.includes(:conversation)
.order(created_at: :desc)
.limit(SOURCE_PREVIEW_LIMIT)
end
def update
@suggestion.with_lock do
raise ActiveRecord::RecordNotFound unless @suggestion.open?
@suggestion.update!(suggestion_params)
end
end
def approve
attributes = params[:faq_suggestion].present? ? suggestion_params : {}
@response = Captain::FaqSuggestionApprovalService.new(@suggestion, attributes).perform
end
def dismiss
@suggestion.with_lock do
raise ActiveRecord::RecordNotFound unless @suggestion.open?
@suggestion.dismissed!
end
end
private
def apply_filters(base_query)
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
if permitted_params[:search].present?
# TODO: Move FAQ suggestion search to Elasticsearch when the records are indexed there.
search_term = "%#{permitted_params[:search]}%"
base_query = base_query.where('question ILIKE :search OR answer ILIKE :search', search: search_term)
end
base_query
end
def set_accessible_suggestions
@suggestions = Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform.includes(:assistant).ordered
end
def set_suggestion
@suggestion = @suggestions.find(permitted_params[:id])
end
def accessible_conversations
Conversations::PermissionFilterService.new(Current.account.conversations, Current.user, Current.account).perform
end
def permitted_params
params.permit(:id, :assistant_id, :page, :status, :search)
end
def suggestion_params
params.require(:faq_suggestion).permit(:question, :answer)
end
end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
@@ -1,6 +1,8 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
PERMISSION_REQUEST_THROTTLE = 5.minutes
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_call_context, only: :initiate
before_action :set_conversation, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
@@ -51,7 +53,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def provider_service
@provider_service ||= @inbox.channel.provider_service
@provider_service ||= @conversation.inbox.channel.provider_service
end
def set_call
@@ -59,38 +61,13 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
authorize @call.conversation, :show?
end
def set_call_context
params[:conversation_id].present? ? set_context_from_conversation : set_context_from_contact
end
def set_context_from_conversation
def set_conversation
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show?
@inbox = @conversation.inbox
@contact = @conversation.contact
end
def set_context_from_contact
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
@contact = Current.account.contacts.find(params[:contact_id])
@conversation = conversation_builder.existing_conversation
# Authorize the thread the call will land in — after the dial is too late to refuse a ringing call.
authorize(@conversation || conversation_builder.new_conversation, :show?)
end
def conversation_builder
@conversation_builder ||= Whatsapp::CallConversationBuilder.new(inbox: @inbox, contact: @contact, user: Current.user)
end
# Created only after the dial succeeds, so a failed call leaves no empty thread and there is nothing to
# roll back. Re-authorized because a concurrent caller may have created the thread we get back.
def open_conversation!
(@conversation || conversation_builder.perform!).tap { |conversation| authorize conversation, :show? }
end
def ensure_calling_enabled
channel = @inbox.channel
channel = @conversation.inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
@@ -103,7 +80,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def ensure_contact_phone
return if @contact.phone_number.present?
return if @conversation.contact&.phone_number.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
@@ -128,45 +105,92 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def create_outbound_call
# A reused thread unassigned at click time is claimed for the caller (wins over auto-assignment); a
# fresh thread (@conversation nil until the dial succeeds) is created already assigned to the caller.
claim_for_caller = @conversation.present? && @conversation.assignee_id.nil?
contact_phone = @conversation.contact.phone_number.delete('+')
# Claim for the caller only if unassigned at trigger time (before the round-trip); wins over auto-assignment.
claim_for_caller = @conversation.assignee_id.nil?
result = provider_service.initiate_call(@contact.phone_number.delete('+'), params[:sdp_offer])
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
@conversation = open_conversation!
@conversation.with_lock { @conversation.update!(assignee: Current.user) } if claim_for_caller
create_call_record(provider_call_id)
end
def create_call_record(provider_call_id)
existing = Current.account.calls.whatsapp.find_by(provider_call_id: provider_call_id)
return existing if existing
Current.account.calls.create!(
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
accepted_by_agent_id: Current.user.id,
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
)
rescue ActiveRecord::RecordNotUnique
# A webhook inserted the row between the find_by above and this create; reconcile to it.
Current.account.calls.whatsapp.find_by!(provider_call_id: provider_call_id)
end
# Meta error 138006 means the contact hasn't opted in yet; send the opt-in
# template (throttled, behind a conversation lock to prevent double-send).
def render_permission_request
# Raised mid-dial, so a fresh contact has no thread yet — open one for the opt-in template to land in.
@conversation = open_conversation!
status = Whatsapp::CallPermissionRequestService.new(conversation: @conversation).perform
status = nil
@conversation.with_lock do
if permission_request_throttled?
status = 'permission_pending'
next
end
sent = send_permission_request_safely
if sent
record_permission_request_wamid(sent)
emit_permission_requested_activity
status = 'permission_requested'
else
status = 'failed'
end
end
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
# 422 (not 200) so any client treating 2xx as "call placed" can't mistake
# the permission-template path for a successful dial. The FE composable
# detects this status and surfaces the banner instead of throwing.
render json: { status: status, conversation_id: @conversation.display_id }, status: :unprocessable_entity
render json: { status: status }, status: :unprocessable_entity
end
def permission_request_throttled?
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
end
# Treat transport errors as a falsy return so we render 422 rather than 500.
def send_permission_request_safely
provider_service.send_call_permission_request(
@conversation.contact.phone_number.delete('+'),
*permission_request_body_args
)
rescue StandardError => e
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
nil
end
# Pass the inbox-level override only when present so the provider falls back
# to the i18n default for inboxes that haven't customized the prompt.
def permission_request_body_args
custom_body = @conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence
custom_body ? [custom_body] : []
end
def emit_permission_requested_activity
content = I18n.t(
'conversations.activity.whatsapp_call.permission_requested',
contact_name: @conversation.contact.name
)
::Conversations::ActivityMessageJob.perform_later(
@conversation,
{ account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: :activity, content: content }
)
end
# Stash the outbound wamid so the reply webhook can match context.id back here.
def record_permission_request_wamid(sent)
attrs = (@conversation.additional_attributes || {}).merge(
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
)
@conversation.update!(additional_attributes: attrs)
end
def render_call_error(error)
@@ -1,8 +1,6 @@
module Enterprise::Api::V1::Accounts::AgentsController
def create
super
return if @agent.blank?
associate_agent_with_custom_role
end
@@ -107,8 +107,8 @@ class Twilio::VoiceController < ApplicationController
when 'inbound'
Voice::InboundCallBuilder.perform!(
inbox: inbox,
call_sid: twilio_call_sid,
caller: { source_ids: [twilio_from], contact_attributes: { name: twilio_from, phone_number: twilio_from } }
from_number: twilio_from,
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
@@ -1,26 +0,0 @@
class Captain::FaqSuggestionFinder
def initialize(current_user, current_account)
@current_user = current_user
@current_account = current_account
end
def perform
suggestions = @current_account.captain_faq_suggestions
return suggestions if account_user&.administrator?
accessible_suggestion_ids = Captain::FaqObservation
.where(conversation_id: accessible_conversations.select(:id))
.select(:faq_suggestion_id)
suggestions.where(id: accessible_suggestion_ids)
end
private
def accessible_conversations
Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform
end
def account_user
@account_user ||= @current_account.account_users.find_by(user_id: @current_user.id)
end
end
@@ -1,30 +0,0 @@
class Captain::Llm::ConversationFaqJob < MutexApplicationJob
queue_as :low
LOCK_TIMEOUT = 10.minutes
retry_on_lock_conflict wait: 30.seconds, attempts: 30
def perform(conversation, assistant)
inbox = conversation.inbox
return unless conversation.resolved?
return unless inbox.captain_active?
return if assistant.config['feature_faq'].blank?
with_lock(lock_key(assistant, conversation), LOCK_TIMEOUT) do
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_suggestions
end
end
private
def lock_key(assistant, conversation)
format(
::Redis::Alfred::CAPTAIN_CONVERSATION_FAQ_MUTEX,
assistant_id: assistant.id,
language: Captain::Llm::ConversationFaqService.language_for(conversation)
)
end
end
+1 -1
View File
@@ -8,6 +8,6 @@ class CaptainListener < BaseListener
return unless conversation.inbox.captain_active?
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
Captain::Llm::ConversationFaqJob.perform_later(conversation, assistant) if assistant.config['feature_faq'].present?
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
end
end
@@ -7,11 +7,7 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
def metrics?
true
end
def faq_stats?
def stats?
true
end
@@ -35,14 +31,6 @@ class Captain::AssistantPolicy < ApplicationPolicy
@account_user.administrator?
end
def approve?
update?
end
def dismiss?
update?
end
def destroy?
@account_user.administrator?
end

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