feat: Implementa insights de CRM na conversação, adiciona integração WhatsApp Baileys e aprimora a integração Z-API e serviços LLM.

This commit is contained in:
Rodrigo Borba 2026-01-04 13:28:41 -03:00
parent c6ef97fc00
commit 47226765b9
75 changed files with 4669 additions and 128 deletions

View File

@ -0,0 +1,71 @@
class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Accounts::BaseController
before_action :fetch_conversation
def show
render json: insights_payload
end
def refresh
result = CrmInsights::UpdateService.new(conversation: @conversation, reason: 'manual').call
render json: insights_payload.merge(meta: build_meta(result))
end
private
def fetch_conversation
@conversation = Current.account.conversations.find(params[:conversation_id])
end
def serialize_insight(insight)
return nil if insight.blank?
{
id: insight.id,
conversation_id: insight.conversation_id,
account_id: insight.account_id,
contact_id: insight.contact_id,
summary_text: insight.summary_text,
structured_data: insight.structured_data,
contact_sessions_count: insight.contact_sessions_count,
last_contact_at: insight.last_contact_at,
updated_at: insight.updated_at,
generated_at: insight.generated_at,
range_from_message_id: insight.range_from_message_id,
range_to_message_id: insight.range_to_message_id,
status: insight.status,
error_message: insight.error_message,
schema_version: insight.schema_version,
model: insight.model,
confidence: insight.confidence
}
end
def insights_payload
insights = @conversation.crm_insights.order(generated_at: :desc)
latest_success = @conversation.latest_crm_insight
latest_attempt = @conversation.latest_crm_insight_attempt
{
crm_insight: serialize_insight(latest_success),
latest_attempt: serialize_insight(latest_attempt),
history: insights.limit(20).map { |item| serialize_insight(item) },
history_count: insights.count
}
end
def build_meta(result)
return nil if result.blank?
meta = {
status: result[:status]
}
if result[:status] == 'no_delta'
last_success = @conversation.latest_crm_insight
meta[:last_success_at] = last_success&.generated_at
elsif result[:status] == 'failed'
meta[:message] = result[:error_message]
end
meta
end
end

View File

@ -8,11 +8,26 @@ class Webhooks::WhatsappController < ActionController::API
return return
end end
perform_whatsapp_events_job
end
private
def perform_whatsapp_events_job
perform_sync if params[:awaitResponse].present?
return if performed?
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok head :ok
end end
private def perform_sync
Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash)
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
head :unauthorized
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
head :not_found
end
def valid_token?(token) def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])

45
app/helpers/baileys_helper.rb Executable file
View File

@ -0,0 +1,45 @@
module BaileysHelper
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY = 'BAILEYS::CHANNEL_LOCK_ON_OUTGOING_MESSAGE::%<channel_id>s'.freeze
CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT = 15.seconds
def baileys_extract_message_timestamp(timestamp)
# NOTE: Timestamp might be in this format {"low"=>1748003165, "high"=>0, "unsigned"=>true}
if timestamp.is_a?(Hash) && timestamp.key?('low')
low = timestamp['low'].to_i
high = timestamp.fetch('high', 0).to_i
return (high << 32) | low
end
# NOTE: Timestamp might be a string or a number
timestamp.to_i
end
def with_baileys_channel_lock_on_outgoing_message(channel_id, timeout: CHANNEL_LOCK_ON_OUTGOING_MESSAGE_TIMEOUT)
raise ArgumentError, 'A block is required for with_baileys_channel_lock_on_outgoing_message' unless block_given?
start_time = Time.now.to_i
# NOTE: On timeout, we ignore the lock and proceed with the block execution
while (Time.now.to_i - start_time) < timeout
break if baileys_lock_channel_on_outgoing_message(channel_id, timeout)
sleep(0.1)
end
yield
ensure
baileys_clear_channel_lock_on_outgoing_message(channel_id)
end
private
def baileys_lock_channel_on_outgoing_message(channel_id, timeout)
key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id)
Redis::Alfred.set(key, 1, nx: true, ex: timeout)
end
def baileys_clear_channel_lock_on_outgoing_message(channel_id)
key = format(CHANNEL_LOCK_ON_OUTGOING_MESSAGE_KEY, channel_id: channel_id)
Redis::Alfred.delete(key)
end
end

View File

@ -13,6 +13,14 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) { updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels }); return axios.post(`${this.url}/${conversationID}/labels`, { labels });
} }
getCrmInsight(conversationID) {
return axios.get(`${this.url}/${conversationID}/crm_insight`);
}
refreshCrmInsight(conversationID) {
return axios.post(`${this.url}/${conversationID}/crm_insight/refresh`);
}
} }
export default new ConversationApi(); export default new ConversationApi();

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg fill="#2781F6"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 302.816 302.816">
<path d="M298.423,152.996c-5.857-5.858-15.354-5.858-21.213,0l-35.137,35.136
c-5.871-59.78-50.15-111.403-112.001-123.706c-45.526-9.055-92.479,5.005-125.596,37.612c-5.903,5.813-5.977,15.31-0.165,21.213
c5.813,5.903,15.31,5.977,21.212,0.164c26.029-25.628,62.923-36.679,98.695-29.565c48.865,9.72,83.772,50.677,88.07,97.978
l-38.835-38.835c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l62.485,62.485
c2.929,2.929,6.768,4.393,10.606,4.393s7.678-1.464,10.607-4.393l62.483-62.482C304.281,168.352,304.281,158.854,298.423,152.996z" />
</svg>

After

Width:  |  Height:  |  Size: 691 B

View File

@ -25,25 +25,47 @@ const isContactSidebarOpen = computed(
const isCopilotPanelOpen = computed( const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open () => uiSettings.value.is_copilot_panel_open
); );
const isCrmInsightsOpen = computed(() => uiSettings.value.is_crm_insights_open);
const toggleConversationSidebarToggle = () => { const toggleConversationSidebarToggle = () => {
if (isCrmInsightsOpen.value) {
updateUISettings({
is_crm_insights_open: false,
});
return;
}
updateUISettings({ updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value, is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false, is_copilot_panel_open: false,
is_crm_insights_open: false,
}); });
}; };
const handleConversationSidebarToggle = () => { const handleConversationSidebarToggle = () => {
if (isCrmInsightsOpen.value) {
updateUISettings({
is_crm_insights_open: false,
});
return;
}
updateUISettings({ updateUISettings({
is_contact_sidebar_open: true, is_contact_sidebar_open: true,
is_copilot_panel_open: false, is_copilot_panel_open: false,
is_crm_insights_open: false,
}); });
}; };
const handleCopilotSidebarToggle = () => { const handleCopilotSidebarToggle = () => {
if (isCrmInsightsOpen.value) {
updateUISettings({
is_crm_insights_open: false,
});
return;
}
updateUISettings({ updateUISettings({
is_contact_sidebar_open: false, is_contact_sidebar_open: false,
is_copilot_panel_open: true, is_copilot_panel_open: true,
is_crm_insights_open: false,
}); });
}; };

View File

@ -0,0 +1,127 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
variant: {
type: String,
default: 'info',
validator: value => ['info', 'success', 'warning'].includes(value),
},
ctaText: {
type: String,
default: '',
},
ctaLink: {
type: String,
default: '',
},
ctaExternal: {
type: Boolean,
default: false,
},
showIcon: {
type: Boolean,
default: true,
},
logoSrc: {
type: String,
default: '',
},
logoAlt: {
type: String,
default: 'Logo',
},
});
const emit = defineEmits(['ctaClick']);
const variantClasses = computed(() => {
const variants = {
info: {
container: 'bg-woot-50 border-woot-200',
icon: 'i-lucide-info text-woot-600',
text: 'text-woot-700',
description: 'text-woot-600',
},
success: {
container: 'bg-green-50 border-green-200',
icon: 'i-lucide-sparkles text-green-600',
text: 'text-green-700',
description: 'text-green-600',
},
warning: {
container: 'bg-yellow-50 border-yellow-200',
icon: 'i-lucide-alert-circle text-yellow-600',
text: 'text-yellow-700',
description: 'text-yellow-600',
},
};
return variants[props.variant];
});
const handleCtaClick = () => {
emit('ctaClick');
};
</script>
<template>
<div
class="relative flex items-start gap-3 p-4 rounded-lg border"
:class="variantClasses.container"
>
<div v-if="logoSrc || showIcon" class="flex-shrink-0 mt-0.5">
<img
v-if="logoSrc"
:src="logoSrc"
:alt="logoAlt"
class="w-8 h-8 object-contain"
/>
<i v-else class="w-5 h-5" :class="variantClasses.icon" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold mb-1" :class="variantClasses.text">
{{ title }}
</h3>
<p class="text-sm leading-relaxed" :class="variantClasses.description">
{{ description }}
</p>
<div v-if="ctaText" class="mt-3">
<a
v-if="ctaLink"
:href="ctaLink"
:target="ctaExternal ? '_blank' : '_self'"
:rel="ctaExternal ? 'noopener noreferrer' : undefined"
class="inline-block"
>
<NextButton
sm
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
type="button"
>
{{ ctaText }}
</NextButton>
</a>
<NextButton
v-else
sm
:color-scheme="variant === 'success' ? 'primary' : 'secondary'"
type="button"
@click="handleCtaClick"
>
{{ ctaText }}
</NextButton>
</div>
</div>
</div>
</template>

View File

@ -103,7 +103,9 @@ const llmProviderOptions = [
]; ];
const llmProviderLabel = computed(() => { const llmProviderLabel = computed(() => {
const option = llmProviderOptions.find(opt => opt.value === state.llmProvider); const option = llmProviderOptions.find(
opt => opt.value === state.llmProvider
);
return option ? option.label : 'Selecione um provedor'; return option ? option.label : 'Selecione um provedor';
}); });
@ -111,23 +113,44 @@ const llmProviderLabel = computed(() => {
const llmModelOptions = computed(() => { const llmModelOptions = computed(() => {
if (state.llmProvider === 'openai') { if (state.llmProvider === 'openai') {
return [ return [
{ value: 'gpt-4o', label: 'GPT-4o (Mais Inteligente)' }, { value: 'gpt-5.2', label: 'GPT-5.2 (Mais Potente)' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini (Rápido e Econômico)' }, { value: 'gpt-5.2-pro', label: 'GPT-5.2 Pro (Premium)' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, { value: 'gpt-5.1', label: 'GPT-5.1' },
{ value: 'gpt-3.5-turbo-0125', label: 'GPT-3.5 Turbo' }, { value: 'gpt-5', label: 'GPT-5' },
{ value: 'gpt-5-mini', label: 'GPT-5 Mini (Custo/Beneficio)' },
{ value: 'gpt-5-nano', label: 'GPT-5 Nano (Super Economico)' },
{ value: 'gpt-4.1', label: 'GPT-4.1 (Estavel)' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini (Barato)' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini (Rapido)' },
]; ];
} else if (state.llmProvider === 'gemini') { }
if (state.llmProvider === 'gemini') {
return [ return [
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' }, { value: 'gemini-3-pro', label: 'Gemini 3 Pro (Mais Potente)' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash (Rápido)' }, { value: 'gemini-3-flash', label: 'Gemini 3 Flash (Rapido)' },
{ value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash (Experimental)' }, { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro (Equilibrado)' },
{
value: 'gemini-2.5-flash',
label: 'Gemini 2.5 Flash (Rapido/Economico)',
},
{
value: 'gemini-2.5-flash-lite',
label: 'Gemini 2.5 Flash Lite (Super Economico)',
},
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (Leve)' },
{
value: 'gemini-2.0-flash-lite',
label: 'Gemini 2.0 Flash Lite (Economico)',
},
]; ];
} }
return []; return [];
}); });
const llmModelLabel = computed(() => { const llmModelLabel = computed(() => {
const option = llmModelOptions.value.find(opt => opt.value === state.llmModel); const option = llmModelOptions.value.find(
opt => opt.value === state.llmModel
);
return option ? option.label : state.llmModel || 'Selecione um modelo'; return option ? option.label : state.llmModel || 'Selecione um modelo';
}); });

View File

@ -13,6 +13,8 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox'; import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({ const props = defineProps({
chat: { chat: {
@ -28,6 +30,7 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const route = useRoute(); const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const conversationHeader = ref(null); const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader); const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox(); const { isAWebWidgetInbox } = useInbox();
@ -90,6 +93,16 @@ const hasMultipleInboxes = computed(
); );
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const isCrmInsightsOpen = computed(() => uiSettings.value.is_crm_insights_open);
const toggleCrmInsights = () => {
updateUISettings({
is_crm_insights_open: !isCrmInsightsOpen.value,
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
};
</script> </script>
<template> <template>
@ -151,6 +164,17 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:parent-width="width" :parent-width="width"
class="hidden md:flex" class="hidden md:flex"
/> />
<Button
v-tooltip.top="t('CONVERSATION.CRM_INSIGHTS.TOGGLE')"
ghost
slate
sm
icon="i-lucide-brain"
:class="{
'bg-n-alpha-2': isCrmInsightsOpen,
}"
@click="toggleCrmInsights"
/>
<MoreActions :conversation-id="currentChat.id" /> <MoreActions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>

View File

@ -0,0 +1,773 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { formatToTitleCase } from 'dashboard/helper/commons';
import wootConstants from 'dashboard/constants/globals';
import ConversationAPI from 'dashboard/api/conversations';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
const props = defineProps({
currentChat: {
required: true,
type: Object,
},
});
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const store = useStore();
const getters = useStoreGetters();
const { accountLabels, activeLabels, onUpdateLabels } = useConversationLabels();
const isOpen = computed(() => uiSettings.value.is_crm_insights_open);
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const insight = ref(null);
const latestAttempt = ref(null);
const historyEntries = ref([]);
const historyCount = ref(0);
const noDeltaMessage = ref('');
const viewMode = ref('detail');
const selectedInsightId = ref(null);
const isLoading = ref(false);
const isRefreshing = ref(false);
const errorMessage = ref('');
const formatDateTime = value => {
if (!value) return t('CONVERSATION.CRM_INSIGHTS.NOT_AVAILABLE');
const date = new Date(value);
return new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
}).format(date);
};
const selectedInsight = computed(() => {
if (selectedInsightId.value) {
return (
historyEntries.value.find(item => item.id === selectedInsightId.value) ||
insight.value
);
}
return insight.value;
});
const activeInsight = computed(() => selectedInsight.value || insight.value);
const normalizedHistory = computed(() => {
const items = historyEntries.value || [];
if (insight.value && !items.find(item => item.id === insight.value.id)) {
return [insight.value, ...items];
}
return items;
});
const structuredData = computed(() => {
const rawValue = activeInsight.value?.structured_data;
if (!rawValue) return {};
if (typeof rawValue === 'string') {
try {
return JSON.parse(rawValue);
} catch (error) {
return {};
}
}
if (typeof rawValue === 'object') return rawValue;
return {};
});
const summaryText = computed(
() =>
structuredData.value?.summary_text ||
activeInsight.value?.summary_text ||
''
);
const hasInsights = computed(
() => summaryText.value || Object.keys(structuredData.value).length > 0
);
const normalizedList = value => {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean);
return [value].filter(Boolean);
};
const formatValue = value => {
if (typeof value === 'string') return formatToTitleCase(value);
if (typeof value === 'number') return value.toString();
if (typeof value === 'boolean')
return value ? t('GENERAL.YES') : t('GENERAL.NO');
return String(value);
};
const preferences = computed(() => {
const rawPreferences = structuredData.value?.preferences;
if (!rawPreferences) return [];
if (Array.isArray(rawPreferences)) {
return rawPreferences.map(item => formatValue(item));
}
if (typeof rawPreferences === 'object') {
return Object.entries(rawPreferences).flatMap(([key, value]) => {
const items = normalizedList(value).map(item => formatValue(item));
if (!items.length) return [];
const label = formatToTitleCase(key);
return items.map(item => `${label}: ${item}`);
});
}
return [formatValue(rawPreferences)];
});
const frictions = computed(() =>
normalizedList(structuredData.value?.frictions).map(item => formatValue(item))
);
const suggestedLabels = computed(() =>
normalizedList(structuredData.value?.suggested_labels).map(item =>
String(item).trim()
)
);
const availableSuggestedLabels = computed(() => {
const labelTitles = new Set(accountLabels.value.map(label => label.title));
return suggestedLabels.value.filter(label => labelTitles.has(label));
});
const contactPattern = computed(
() => structuredData.value?.contact_pattern || {}
);
const intent = computed(() => structuredData.value?.intent);
const urgency = computed(() => structuredData.value?.urgency);
const priceSensitivity = computed(
() => structuredData.value?.price_sensitivity
);
const confidence = computed(() => structuredData.value?.confidence);
const nba = computed(() => structuredData.value?.nba || {});
const generatedAt = computed(() => structuredData.value?.generated_at);
const agentTip = computed(() => structuredData.value?.agent_tip);
const urgencyLabel = computed(() => {
if (!urgency.value) return '';
return t('CONVERSATION.CRM_INSIGHTS.URGENCY_VALUE', {
value: urgency.value,
});
});
const statusLabel = status => {
const normalized = (status || 'success').toString().toLowerCase();
const labels = {
success: t('CONVERSATION.CRM_INSIGHTS.STATUS.SUCCESS'),
failed: t('CONVERSATION.CRM_INSIGHTS.STATUS.FAILED'),
};
return labels[normalized] || labels.success;
};
const formatConfidence = value => {
if (typeof value !== 'number') return '';
if (value <= 1) return `${Math.round(value * 100)}%`;
return `${Math.round(value)}%`;
};
const urgencyToPriority = value => {
if (typeof value !== 'number') return null;
if (value >= 5) return CONVERSATION_PRIORITY.URGENT;
if (value >= 4) return CONVERSATION_PRIORITY.HIGH;
if (value >= 3) return CONVERSATION_PRIORITY.MEDIUM;
if (value >= 1) return CONVERSATION_PRIORITY.LOW;
return null;
};
const applySuggestedLabels = async () => {
if (!props.currentChat?.id) return;
const currentLabels = activeLabels.value.map(label => label.title);
const merged = Array.from(
new Set([...currentLabels, ...availableSuggestedLabels.value])
);
if (!merged.length) return;
await onUpdateLabels(merged);
};
const setPriorityFromUrgency = async () => {
if (!props.currentChat?.id) return;
const priority = urgencyToPriority(Number(urgency.value));
if (!priority) return;
await store.dispatch('assignPriority', {
conversationId: props.currentChat.id,
priority,
});
};
const insertSuggestedReply = async () => {
if (!props.currentChat?.id || !agentTip.value) return;
emitter.emit(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, agentTip.value);
};
const createInternalNote = async () => {
if (!props.currentChat?.id || !summaryText.value) return;
await store.dispatch('createPendingMessageAndSend', {
conversationId: props.currentChat.id,
message: summaryText.value,
private: true,
sender: getters.getCurrentUser?.value,
});
};
const loadInsight = async () => {
if (!props.currentChat?.id) return;
isLoading.value = true;
errorMessage.value = '';
noDeltaMessage.value = '';
try {
const { data } = await ConversationAPI.getCrmInsight(props.currentChat.id);
insight.value = data.crm_insight;
latestAttempt.value = data.latest_attempt;
historyEntries.value = data.history || [];
historyCount.value = data.history_count || 0;
if (insight.value?.id) {
selectedInsightId.value = insight.value.id;
viewMode.value = 'detail';
} else {
viewMode.value = 'list';
}
} catch (error) {
errorMessage.value = t('CONVERSATION.CRM_INSIGHTS.LOAD_ERROR');
} finally {
isLoading.value = false;
}
};
const refreshInsight = async () => {
if (!props.currentChat?.id) return;
isRefreshing.value = true;
errorMessage.value = '';
try {
const { data } = await ConversationAPI.refreshCrmInsight(
props.currentChat.id
);
insight.value = data.crm_insight;
latestAttempt.value = data.latest_attempt;
historyEntries.value = data.history || [];
historyCount.value = data.history_count || 0;
if (data.meta?.status === 'no_delta') {
const formattedTime = formatDateTime(data.meta?.last_success_at);
noDeltaMessage.value = t('CONVERSATION.CRM_INSIGHTS.NO_DELTA', {
time: formattedTime,
});
} else {
noDeltaMessage.value = '';
}
if (data.crm_insight?.id) {
selectedInsightId.value = data.crm_insight.id;
viewMode.value = 'detail';
}
} catch (error) {
errorMessage.value = t('CONVERSATION.CRM_INSIGHTS.REFRESH_ERROR');
} finally {
isRefreshing.value = false;
}
};
const closePanel = () => {
updateUISettings({
is_crm_insights_open: false,
});
};
const goBackToList = () => {
viewMode.value = 'list';
};
const openInsightDetail = insightId => {
selectedInsightId.value = insightId;
viewMode.value = 'detail';
};
const hasFailedAttempt = computed(
() =>
latestAttempt.value?.status === 'failed' &&
latestAttempt.value?.generated_at
);
const failureMessage = computed(() => latestAttempt.value?.error_message);
const errorDetail = computed(() => {
if (!failureMessage.value) return '';
return t('CONVERSATION.CRM_INSIGHTS.ERROR_DETAIL', {
error: failureMessage.value,
});
});
watch(
() => props.currentChat?.id,
() => {
if (isOpen.value) loadInsight();
}
);
watch(
() => isOpen.value,
open => {
if (open) loadInsight();
}
);
</script>
<template>
<div
v-on-click-outside="() => (isSmallScreen ? closePanel() : null)"
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': isOpen,
'md:hidden': !isOpen,
},
]"
>
<div
class="flex items-center justify-between px-4 py-3 border-b border-n-weak"
>
<div class="flex items-center gap-2">
<Button
v-if="viewMode === 'detail'"
xs
ghost
slate
icon="i-lucide-arrow-left"
@click="goBackToList"
/>
<span class="text-sm font-medium text-n-slate-12">
{{
viewMode === 'detail'
? t('CONVERSATION.CRM_INSIGHTS.LATEST.TITLE')
: `${t('CONVERSATION.CRM_INSIGHTS.HISTORY.TITLE')} (${historyCount})`
}}
</span>
</div>
<div class="flex items-center gap-2">
<Button
xs
ghost
slate
:is-loading="isRefreshing"
icon="i-lucide-refresh-cw"
@click="refreshInsight"
>
{{ t('CONVERSATION.CRM_INSIGHTS.REFRESH') }}
</Button>
<Button xs ghost slate icon="i-lucide-x" @click="closePanel" />
</div>
</div>
<div class="flex flex-col gap-4 p-4 overflow-auto">
<div class="text-xs text-n-slate-10">
<div>
{{ `${t('CONVERSATION.CRM_INSIGHTS.UPDATED_AT')}:` }}
{{
formatDateTime(
activeInsight?.generated_at || activeInsight?.updated_at
)
}}
</div>
<div v-if="activeInsight?.range_to_message_id">
{{ `${t('CONVERSATION.CRM_INSIGHTS.RANGE_TO')}:` }}
{{ activeInsight.range_to_message_id }}
</div>
<div>
{{ `${t('CONVERSATION.CRM_INSIGHTS.CONTACT_SESSIONS')}:` }}
{{ activeInsight?.contact_sessions_count ?? 0 }}
</div>
<div>
{{ `${t('CONVERSATION.CRM_INSIGHTS.LAST_CONTACT')}:` }}
{{ formatDateTime(activeInsight?.last_contact_at) }}
</div>
</div>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Spinner size="32" class="text-n-slate-10" />
</div>
<div v-else-if="errorMessage" class="text-sm text-n-ruby-9">
{{ errorMessage }}
</div>
<div
v-else-if="hasFailedAttempt && !hasInsights"
class="text-sm text-n-amber-11"
>
{{ t('CONVERSATION.CRM_INSIGHTS.REFRESH_ERROR') }}
<span v-if="errorDetail">{{ errorDetail }}</span>
</div>
<div v-else-if="viewMode === 'list'" class="flex flex-col gap-3">
<div
v-if="insight"
class="rounded-2xl border border-n-weak p-4 cursor-pointer bg-n-amber-1 text-n-amber-11 shadow-sm"
@click="openInsightDetail(insight.id)"
>
<div
class="flex items-center gap-2 text-[10px] uppercase tracking-wide font-semibold"
>
<span class="i-lucide-pin text-base" />
{{ t('CONVERSATION.CRM_INSIGHTS.LATEST.TITLE') }}
</div>
<div class="mt-2 flex items-center justify-between text-xs">
<span>{{
formatDateTime(insight.generated_at || insight.updated_at)
}}</span>
<span class="uppercase text-[10px]">
{{ statusLabel('success') }}
</span>
</div>
<div class="mt-3 text-sm">
{{ `${t('CONVERSATION.CRM_INSIGHTS.RANGE_TO')}:` }}
{{
insight.range_to_message_id ||
t('CONVERSATION.CRM_INSIGHTS.NOT_AVAILABLE')
}}
</div>
<div
v-if="insight.summary_text"
class="mt-3 text-sm text-n-amber-12 line-clamp-3"
>
{{ insight.summary_text }}
</div>
</div>
<div
v-for="item in normalizedHistory.filter(
item => item.id !== insight?.id
)"
:key="item.id"
class="rounded-xl border border-n-weak p-3 cursor-pointer"
:class="[
item.status === 'failed'
? 'bg-n-ruby-1 text-n-ruby-9'
: 'bg-n-sky-1 text-n-slate-12',
]"
@click="openInsightDetail(item.id)"
>
<div class="flex items-center justify-between text-xs">
<span>{{
formatDateTime(item.generated_at || item.updated_at)
}}</span>
<span class="uppercase text-[10px]">
{{ statusLabel(item.status) }}
</span>
</div>
<div class="mt-2 text-sm">
{{ `${t('CONVERSATION.CRM_INSIGHTS.RANGE_TO')}:` }}
{{
item.range_to_message_id ||
t('CONVERSATION.CRM_INSIGHTS.NOT_AVAILABLE')
}}
</div>
<div
v-if="item.summary_text"
class="mt-2 text-xs text-n-slate-10 line-clamp-2"
>
{{ item.summary_text }}
</div>
<div v-else-if="item.error_message" class="mt-2 text-xs">
{{ item.error_message }}
</div>
</div>
<div v-if="!normalizedHistory.length" class="text-sm text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.EMPTY_STATE') }}
</div>
</div>
<div v-else-if="hasInsights" class="flex flex-col gap-4">
<div
class="rounded-xl border border-n-weak p-4 space-y-3 bg-n-strong/30"
>
<div class="flex items-center justify-between">
<div
class="text-xs font-semibold text-n-slate-10 uppercase tracking-wide"
>
{{ t('CONVERSATION.CRM_INSIGHTS.LATEST.TITLE') }}
</div>
<span class="text-[10px] text-n-slate-10">
{{
formatDateTime(
activeInsight?.generated_at || activeInsight?.updated_at
)
}}
</span>
</div>
<div
v-if="summaryText"
class="text-sm text-n-slate-12 whitespace-pre-line leading-6"
>
{{ summaryText }}
</div>
<div class="text-xs text-n-slate-10">
{{ `${t('CONVERSATION.CRM_INSIGHTS.RANGE_TO')}:` }}
{{
activeInsight?.range_to_message_id ||
t('CONVERSATION.CRM_INSIGHTS.NOT_AVAILABLE')
}}
</div>
</div>
<div
v-if="noDeltaMessage"
class="text-sm text-n-amber-11 border border-n-weak bg-n-amber-1 rounded-lg px-3 py-2"
>
{{ noDeltaMessage }}
</div>
<div
v-if="hasFailedAttempt"
class="text-xs text-n-amber-11 border border-n-weak bg-n-amber-1 rounded-lg px-3 py-2"
>
{{ t('CONVERSATION.CRM_INSIGHTS.REFRESH_ERROR') }}
<span v-if="errorDetail">{{ errorDetail }}</span>
{{ t('CONVERSATION.CRM_INSIGHTS.SEPARATOR') }}
{{ formatDateTime(latestAttempt?.generated_at) }}
</div>
<div class="grid gap-3">
<div v-if="intent" class="rounded-xl border border-n-weak p-3">
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.INTENT') }}
</div>
<div class="text-sm text-n-slate-12">
{{ formatValue(intent) }}
</div>
</div>
<div v-if="urgency" class="rounded-xl border border-n-weak p-3">
<div>
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.URGENCY') }}
</div>
<div class="text-sm text-n-slate-12">
{{ urgencyLabel }}
</div>
</div>
</div>
<div
v-if="preferences.length"
class="rounded-xl border border-n-weak p-3"
>
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.PREFERENCES') }}
</div>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-for="item in preferences"
:key="item"
class="text-xs text-n-slate-12 bg-n-strong px-2 py-1 rounded-full"
>
{{ item }}
</span>
</div>
</div>
<div
v-if="priceSensitivity"
class="rounded-xl border border-n-weak p-3"
>
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.PRICE_SENSITIVITY') }}
</div>
<div class="text-sm text-n-slate-12">
{{ formatValue(priceSensitivity) }}
</div>
</div>
<div
v-if="frictions.length"
class="rounded-xl border border-n-weak p-3"
>
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.FRICTIONS') }}
</div>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-for="item in frictions"
:key="item"
class="text-xs text-n-slate-12 bg-n-strong px-2 py-1 rounded-full"
>
{{ item }}
</span>
</div>
</div>
<div
v-if="contactPattern.time_range || contactPattern.days?.length"
class="rounded-xl border border-n-weak p-3"
>
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.CONTACT_PATTERN') }}
</div>
<div class="text-sm text-n-slate-12">
<div v-if="contactPattern.time_range">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.CONTACT_TIME_LABEL') }}
{{ contactPattern.time_range }}
</div>
<div v-if="contactPattern.days?.length">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.CONTACT_DAYS_LABEL') }}
{{
contactPattern.days.map(day => formatValue(day)).join(', ')
}}
</div>
</div>
</div>
<div
v-if="nba?.action || nba?.reason"
class="rounded-xl border border-n-weak p-3"
>
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.NBA') }}
</div>
<div class="text-sm text-n-slate-12 space-y-1 mt-2">
<div v-if="nba.action">
<span class="font-semibold">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.NBA_ACTION_LABEL') }}
</span>
<span>{{ formatValue(nba.action) }}</span>
</div>
<div v-if="nba.priority">
<span class="font-semibold">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.NBA_PRIORITY_LABEL') }}
</span>
<span>{{ formatValue(nba.priority) }}</span>
</div>
<div v-if="nba.reason">
<span class="font-semibold">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.NBA_REASON_LABEL') }}
</span>
<span>{{ nba.reason }}</span>
</div>
</div>
</div>
<div
v-if="suggestedLabels.length"
class="rounded-xl border border-n-weak p-3"
>
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.SUGGESTED_LABELS') }}
</div>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-for="item in suggestedLabels"
:key="item"
class="text-xs text-n-slate-12 bg-n-strong px-2 py-1 rounded-full"
>
{{ item }}
</span>
</div>
<div class="mt-3">
<Button
xs
outline
slate
:disabled="!availableSuggestedLabels.length"
icon="i-lucide-tag"
@click="applySuggestedLabels"
>
{{ t('CONVERSATION.CRM_INSIGHTS.ACTIONS.APPLY_LABELS') }}
</Button>
</div>
</div>
<div v-if="confidence" class="rounded-xl border border-n-weak p-3">
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.CONFIDENCE') }}
</div>
<div class="text-sm text-n-slate-12">
{{ formatConfidence(confidence) }}
</div>
</div>
<div v-if="generatedAt" class="rounded-xl border border-n-weak p-3">
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.CARDS.GENERATED_AT') }}
</div>
<div class="text-sm text-n-slate-12">
{{ formatDateTime(generatedAt) }}
</div>
</div>
</div>
<div class="rounded-xl border border-n-weak p-3">
<div class="text-xs font-medium text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.ACTIONS.TITLE') }}
</div>
<div class="flex flex-wrap gap-2 mt-3">
<Button
xs
outline
slate
icon="i-lucide-alert-triangle"
:disabled="!urgency"
@click="setPriorityFromUrgency"
>
{{ t('CONVERSATION.CRM_INSIGHTS.ACTIONS.SET_PRIORITY') }}
</Button>
<Button
xs
outline
slate
icon="i-lucide-message-square"
:disabled="!agentTip"
@click="insertSuggestedReply"
>
{{ t('CONVERSATION.CRM_INSIGHTS.ACTIONS.INSERT_REPLY') }}
</Button>
<Button
xs
outline
slate
icon="i-lucide-sticky-note"
:disabled="!summaryText"
@click="createInternalNote"
>
{{ t('CONVERSATION.CRM_INSIGHTS.ACTIONS.CREATE_NOTE') }}
</Button>
</div>
</div>
<div
v-if="historyCount > 1"
class="rounded-xl border border-n-weak p-3"
>
<div class="flex items-center justify-between">
<div class="text-xs font-medium text-n-slate-9">
{{
`${t('CONVERSATION.CRM_INSIGHTS.HISTORY.TITLE')} (${historyCount})`
}}
</div>
<Button
xs
ghost
slate
icon="i-lucide-list"
@click="viewMode = 'list'"
>
{{ t('CONVERSATION.CRM_INSIGHTS.HISTORY.SHOW') }}
</Button>
</div>
</div>
</div>
<div v-else class="text-sm text-n-slate-9">
{{ t('CONVERSATION.CRM_INSIGHTS.EMPTY_STATE') }}
</div>
</div>
</div>
</template>

View File

@ -103,6 +103,65 @@
"DUE": "due" "DUE": "due"
} }
}, },
"CRM_INSIGHTS": {
"TITLE": "AI CRM Insights",
"TOGGLE": "Open AI insights",
"REFRESH": "Refresh summary",
"UPDATED_AT": "Updated at",
"CONTACT_SESSIONS": "Contact sessions (24h)",
"LAST_CONTACT": "Last valid contact",
"EMPTY_STATE": "No summary available yet.",
"LOAD_ERROR": "Could not load the summary.",
"REFRESH_ERROR": "Could not refresh the summary.",
"NOT_AVAILABLE": "Not available",
"NO_DELTA": "Nothing new to summarize since {time}.",
"RANGE_TO": "Summary up to message",
"RANGE_LABEL": "Message range",
"LATEST": {
"TITLE": "Latest valid summary"
},
"ERROR_DETAIL": "Error: {error}",
"SEPARATOR": "•",
"STATUS": {
"SUCCESS": "success",
"FAILED": "failed"
},
"URGENCY_VALUE": "{value}/5",
"CARDS": {
"INTENT": "Intent",
"URGENCY": "Urgency",
"PREFERENCES": "Preferences",
"PRICE_SENSITIVITY": "Price sensitivity",
"FRICTIONS": "Friction points",
"CONTACT_PATTERN": "Contact pattern",
"CONTACT_TIME": "Time",
"CONTACT_DAYS": "Days",
"CONTACT_TIME_LABEL": "Time:",
"CONTACT_DAYS_LABEL": "Days:",
"NBA": "Next best action",
"NBA_ACTION": "Action",
"NBA_PRIORITY": "Priority",
"NBA_REASON": "Reason",
"NBA_ACTION_LABEL": "Action:",
"NBA_PRIORITY_LABEL": "Priority:",
"NBA_REASON_LABEL": "Reason:",
"SUGGESTED_LABELS": "Suggested labels",
"CONFIDENCE": "Confidence",
"GENERATED_AT": "Last generated"
},
"ACTIONS": {
"TITLE": "Quick actions",
"APPLY_LABELS": "Apply suggested labels",
"SET_PRIORITY": "Set priority",
"INSERT_REPLY": "Insert suggested reply",
"CREATE_NOTE": "Create internal note"
},
"HISTORY": {
"TITLE": "History",
"SHOW": "View history",
"HIDE": "Hide history"
}
},
"RESOLVE_DROPDOWN": { "RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending", "MARK_PENDING": "Mark as pending",
"SNOOZE_UNTIL": "Snooze", "SNOOZE_UNTIL": "Snooze",

View File

@ -103,6 +103,65 @@
"DUE": "venceu" "DUE": "venceu"
} }
}, },
"CRM_INSIGHTS": {
"TITLE": "Analise por IA",
"TOGGLE": "Abrir analise por IA",
"REFRESH": "Atualizar resumo",
"UPDATED_AT": "Atualizado em",
"CONTACT_SESSIONS": "Contatos (24h)",
"LAST_CONTACT": "Ultimo contato valido",
"EMPTY_STATE": "Nenhum resumo disponivel ainda.",
"LOAD_ERROR": "Nao foi possivel carregar o resumo.",
"REFRESH_ERROR": "Nao foi possivel atualizar o resumo.",
"NOT_AVAILABLE": "Indisponivel",
"NO_DELTA": "Nada novo para resumir desde {time}.",
"RANGE_TO": "Resumo ate mensagem",
"RANGE_LABEL": "Intervalo de mensagens",
"LATEST": {
"TITLE": "Ultimo resumo valido"
},
"ERROR_DETAIL": "Erro: {error}",
"SEPARATOR": "•",
"STATUS": {
"SUCCESS": "sucesso",
"FAILED": "falhou"
},
"URGENCY_VALUE": "{value}/5",
"CARDS": {
"INTENT": "Intencao",
"URGENCY": "Urgencia",
"PREFERENCES": "Preferencias",
"PRICE_SENSITIVITY": "Sensibilidade a preco",
"FRICTIONS": "Friccoes",
"CONTACT_PATTERN": "Padrao de contato",
"CONTACT_TIME": "Horario",
"CONTACT_DAYS": "Dias",
"CONTACT_TIME_LABEL": "Horario:",
"CONTACT_DAYS_LABEL": "Dias:",
"NBA": "Proxima melhor acao",
"NBA_ACTION": "Acao",
"NBA_PRIORITY": "Prioridade",
"NBA_REASON": "Motivo",
"NBA_ACTION_LABEL": "Acao:",
"NBA_PRIORITY_LABEL": "Prioridade:",
"NBA_REASON_LABEL": "Motivo:",
"SUGGESTED_LABELS": "Etiquetas sugeridas",
"CONFIDENCE": "Confianca",
"GENERATED_AT": "Ultima geracao"
},
"ACTIONS": {
"TITLE": "Acoes rapidas",
"APPLY_LABELS": "Aplicar etiquetas sugeridas",
"SET_PRIORITY": "Definir prioridade",
"INSERT_REPLY": "Inserir sugestao de resposta",
"CREATE_NOTE": "Criar nota interna"
},
"HISTORY": {
"TITLE": "Historico",
"SHOW": "Ver historico",
"HIDE": "Ocultar historico"
}
},
"RESOLVE_DROPDOWN": { "RESOLVE_DROPDOWN": {
"MARK_PENDING": "Deixar pendente", "MARK_PENDING": "Deixar pendente",
"SNOOZE_UNTIL": "Adiar", "SNOOZE_UNTIL": "Adiar",

View File

@ -238,7 +238,11 @@
"TWILIO_DESC": "Conectar através de credenciais Twilio", "TWILIO_DESC": "Conectar através de credenciais Twilio",
"360_DIALOG": "360Dialog", "360_DIALOG": "360Dialog",
"WUZAPI": "Wuzapi", "WUZAPI": "Wuzapi",
"WUZAPI_DESC": "Conecte sua instância Wuzapi" "WUZAPI_DESC": "Conecte sua instância Wuzapi",
"BAILEYS": "Baileys",
"BAILEYS_DESC": "Conectar via API não-oficial Baileys",
"ZAPI": "Z-API",
"ZAPI_DESC": "Conectar via API não-oficial Z-API"
}, },
"WUZAPI": { "WUZAPI": {
"BASE_URL": { "BASE_URL": {
@ -256,7 +260,22 @@
}, },
"SELECT_PROVIDER": { "SELECT_PROVIDER": {
"TITLE": "Selecione seu provedor de API", "TITLE": "Selecione seu provedor de API",
"DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta." "DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta.",
"ZAPI_PROMO": {
"TITLE": "Procurando uma solução WhatsApp confiável?",
"DESCRIPTION": "Z-API oferece estabilidade superior comparado ao Baileys e é muito mais simples de configurar que Cloud ou Twilio - sem necessidade de configuração complexa. Perfeito para empresas que querem começar rapidamente.",
"CTA": "Usar Z-API",
"SWITCH_BANNER": {
"TITLE": "Considere mudar para Z-API para configuração mais fácil",
"DESCRIPTION": "Z-API fornece uma conexão mais estável que Baileys e requer menos configuração que Cloud/Twilio. Mude para uma integração WhatsApp sem complicações.",
"CTA": "Mudar para Z-API"
},
"SETUP_BANNER": {
"TITLE": "Ganhe 10% de desconto na sua assinatura Z-API",
"DESCRIPTION": "Crie sua conta Z-API usando nosso link de afiliado e receba 10% de desconto. Configuração simples, conexões confiáveis e ótimo suporte.",
"CTA": "Criar Conta Z-API"
}
}
}, },
"INBOX_NAME": { "INBOX_NAME": {
"LABEL": "Nome da Caixa de Entrada", "LABEL": "Nome da Caixa de Entrada",
@ -295,37 +314,42 @@
"WEBHOOK_URL": "URL do Webhook", "WEBHOOK_URL": "URL do Webhook",
"WEBHOOK_VERIFICATION_TOKEN": "Token de verificação Webhook" "WEBHOOK_VERIFICATION_TOKEN": "Token de verificação Webhook"
}, },
"SUBMIT_BUTTON": "Criar canal do WhatsApp", "PROVIDER_URL": {
"EMBEDDED_SIGNUP": { "LABEL": "URL do provedor",
"TITLE": "Configuração rápida com Meta", "PLACEHOLDER": "Se o provedor não está rodando localmente, por favor, insira a URL do provedor",
"DESC": "Use o fluxo de inscrição incorporada do WhatsApp para conectar rapidamente novos números. Você será redirecionado para a Meta para entrar na sua conta do WhatsApp Business. Ter acesso de administrador ajudará a tornar a configuração simples e fácil.", "ERROR": "Por favor, insira uma URL válida"
"BENEFITS": {
"TITLE": "Benefícios da inscrição incorporada:",
"EASY_SETUP": "Nenhuma configuração manual é necessária",
"SECURE_AUTH": "Autenticação segura baseada em OAuth",
"AUTO_CONFIG": "Configuração automática de webhook e número de telefone"
},
"LEARN_MORE": {
"TEXT": "Para saber mais sobre a inscrição integrada, preços e limitações, visite {link}.",
"LINK_TEXT": "este link"
},
"SUBMIT_BUTTON": "Conecte-se com WhatsApp Business",
"AUTH_PROCESSING": "Autenticando com Meta",
"WAITING_FOR_BUSINESS_INFO": "Por favor, complete a configuração do negócio na janela da Meta...",
"PROCESSING": "Configurando sua conta do WhatsApp Business",
"LOADING_SDK": "Carregando SDK do Facebook...",
"CANCELLED": "A inscrição no WhatsApp foi cancelada",
"SUCCESS_TITLE": "Conta do WhatsApp Business conectada!",
"WAITING_FOR_AUTH": "Aguardando autenticação...",
"INVALID_BUSINESS_DATA": "Dados de negócio inválidos recebidos do Facebook. Por favor, tente novamente.",
"SIGNUP_ERROR": "Ocorreu um erro no cadastro",
"AUTH_NOT_COMPLETED": "Autenticação não concluída. Por favor, reinicie o processo.",
"SUCCESS_FALLBACK": "A conta do WhatsApp Business foi configurada com sucesso",
"MANUAL_FALLBACK": "Se o seu número já estiver conectado à Plataforma WhatsApp Business (API) ou se você for um provedor de tecnologia integrando o seu próprio número, use o fluxo de {link}",
"MANUAL_LINK_TEXT": "fluxo de configuração manual"
}, },
"API": { "MARK_AS_READ": {
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp" "LABEL": "Enviar confirmações de leitura"
},
"INSTANCE_ID": {
"LABEL": "ID da instância",
"PLACEHOLDER": "Por favor, insira o ID da sua instância",
"ERROR": "Este campo é obrigatório"
},
"TOKEN": {
"LABEL": "Token",
"PLACEHOLDER": "Por favor, insira o Token da sua instância",
"ERROR": "Este campo é obrigatório"
},
"CLIENT_TOKEN": {
"LABEL": "Token de Segurança",
"PLACEHOLDER": "Por favor, insira o Token de Segurança (veja a aba Segurança no painel do Z-API)",
"ERROR": "Este campo é obrigatório"
},
"ADVANCED_OPTIONS": "Opções avançadas",
"EXTERNAL_PROVIDER": {
"SUBTITLE": "Clique abaixo para configurar o canal do WhatsApp.",
"LINK_BUTTON": "Conectar dispositivo",
"LINK_DEVICE_MODAL": {
"TITLE": "Conecte o seu dispositivo",
"SUBTITLE": "Escaneie o QR code para conectar seu dispositivo. Certifique-se de que o número de telefone esteja correto antes de escanear.",
"LOADING_QRCODE": "Carregando QR code...",
"RECONNECTING": "Conectando...",
"LINK_DEVICE": "Conectar dispositivo",
"DISCONNECT": "Desconectar",
"CONNECTED": "Seu dispositivo foi conectado com sucesso. Agora você pode começar a enviar e receber mensagens."
}
} }
}, },
"VOICE": { "VOICE": {

View File

@ -10,6 +10,7 @@ import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBar
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue'; import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue'; import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
import CrmInsightsSidebar from 'dashboard/components/widgets/conversation/CrmInsightsSidebar.vue';
export default { export default {
components: { components: {
@ -18,6 +19,7 @@ export default {
CmdBarConversationSnooze, CmdBarConversationSnooze,
SidepanelSwitch, SidepanelSwitch,
ConversationSidebar, ConversationSidebar,
CrmInsightsSidebar,
}, },
beforeRouteLeave(to, from, next) { beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues // Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
@ -94,7 +96,14 @@ export default {
} }
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings; const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen; return isContactSidebarOpen && !this.isCrmInsightsOpen;
},
isCrmInsightsOpen() {
const { is_crm_insights_open: isCrmInsightsOpen } = this.uiSettings;
return isCrmInsightsOpen;
},
shouldShowCrmInsights() {
return this.currentChat.id && this.isCrmInsightsOpen;
}, },
}, },
watch: { watch: {
@ -214,6 +223,10 @@ export default {
<SidepanelSwitch v-if="currentChat.id" /> <SidepanelSwitch v-if="currentChat.id" />
</ConversationBox> </ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" /> <ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CrmInsightsSidebar
v-if="shouldShowCrmInsights"
:current-chat="currentChat"
/>
<CmdBarConversationSnooze /> <CmdBarConversationSnooze />
</section> </section>
</template> </template>

View File

@ -0,0 +1,207 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required, requiredIf } from '@vuelidate/validators';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import { isValidURL } from '../../../../../helper/URLHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const inboxName = ref('');
const phoneNumber = ref('');
const apiKey = ref('');
const providerUrl = ref('');
const showAdvancedOptions = ref(false);
const markAsRead = ref(true);
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
const rules = computed(() => ({
inboxName: { required },
phoneNumber: { required, isPhoneE164OrEmpty },
providerUrl: {
isValidURL: value => !value || isValidURL(value),
requiredIf: requiredIf(apiKey),
},
apiKey: { requiredIf: requiredIf(providerUrl) },
}));
const v$ = useVuelidate(rules, {
inboxName,
phoneNumber,
providerUrl,
apiKey,
});
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
try {
const providerConfig = {
mark_as_read: markAsRead.value,
};
if (apiKey.value || providerUrl.value) {
providerConfig.api_key = apiKey.value;
providerConfig.url = providerUrl.value;
}
const whatsappChannel = await store.dispatch('inboxes/createChannel', {
name: inboxName.value,
channel: {
type: 'whatsapp',
phone_number: phoneNumber.value,
provider: 'baileys',
provider_config: providerConfig,
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: whatsappChannel.id,
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
}
};
const setShowAdvancedOptions = () => {
showAdvancedOptions.value = true;
};
const switchToZapi = () => {
router.push({
name: router.currentRoute.value.name,
params: router.currentRoute.value.params,
query: { provider: 'zapi' },
});
};
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-full mb-6">
<PromoBanner
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.TITLE')"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.DESCRIPTION')
"
variant="info"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-blue.png"
logo-alt="Z-API"
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SWITCH_BANNER.CTA')"
@cta-click="switchToZapi"
/>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
<input
v-model="inboxName"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="v$.inboxName.$touch"
/>
<span v-if="v$.inboxName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
<input
v-model="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
<span v-if="v$.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div
v-if="!showAdvancedOptions"
class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%] mb-4"
>
<NextButton icon="i-lucide-plus" sm link @click="setShowAdvancedOptions">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.ADVANCED_OPTIONS') }}
</NextButton>
</div>
<template v-else>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<span class="text-sm text-gray-600">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.ADVANCED_OPTIONS') }}
</span>
<label :class="{ error: v$.providerUrl.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.LABEL') }}
<input
v-model="providerUrl"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.PLACEHOLDER')
"
/>
<span v-if="v$.providerUrl.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDER_URL.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.apiKey.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.LABEL') }}
<input
v-model="apiKey"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.PLACEHOLDER')"
/>
<span v-if="v$.apiKey.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label>
<div class="flex mb-2 items-center">
<span class="mr-2 text-sm">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MARK_AS_READ.LABEL') }}
</span>
<Switch id="markAsRead" v-model="markAsRead" />
</div>
</label>
</div>
</template>
<div class="w-full">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
/>
</div>
</form>
</template>

View File

@ -7,7 +7,10 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue'; import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import Wuzapi from './Wuzapi.vue'; import Wuzapi from './Wuzapi.vue';
import ZapiWhatsapp from './ZapiWhatsapp.vue';
import BaileysWhatsapp from './BaileysWhatsapp.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -20,6 +23,8 @@ const PROVIDER_TYPES = {
WHATSAPP_EMBEDDED: 'whatsapp_embedded', WHATSAPP_EMBEDDED: 'whatsapp_embedded',
WHATSAPP_MANUAL: 'whatsapp_manual', WHATSAPP_MANUAL: 'whatsapp_manual',
THREE_SIXTY_DIALOG: '360dialog', THREE_SIXTY_DIALOG: '360dialog',
BAILEYS: 'baileys',
ZAPI: 'zapi',
WUZAPI: 'wuzapi', WUZAPI: 'wuzapi',
}; };
@ -49,6 +54,18 @@ const availableProviders = computed(() => [
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'), description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: 'i-woot-twilio', icon: 'i-woot-twilio',
}, },
{
key: PROVIDER_TYPES.BAILEYS,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS_DESC'),
icon: 'i-woot-baileys',
},
{
key: PROVIDER_TYPES.ZAPI,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI_DESC'),
icon: 'i-woot-zapi',
},
{ {
key: PROVIDER_TYPES.WUZAPI, key: PROVIDER_TYPES.WUZAPI,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI'), title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI'),
@ -99,6 +116,29 @@ const handleManualLinkClick = () => {
@click="selectProvider(provider.key)" @click="selectProvider(provider.key)"
/> />
</div> </div>
<div class="mt-6 relative overflow-visible">
<img
src="~dashboard/assets/images/curved-arrow.svg"
alt=""
class="absolute -top-12 right-64 w-20 h-20 pointer-events-none z-10 scale-y-[-1] -rotate-45"
/>
<PromoBanner
:title="
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.TITLE')
"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.DESCRIPTION')
"
variant="success"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-green.png"
logo-alt="Z-API"
:cta-text="
$t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.ZAPI_PROMO.CTA')
"
@cta-click="selectProvider(PROVIDER_TYPES.ZAPI)"
/>
</div>
</div> </div>
<div v-else-if="showConfiguration"> <div v-else-if="showConfiguration">
@ -139,6 +179,10 @@ const handleManualLinkClick = () => {
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" /> <CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Wuzapi v-else-if="selectedProvider === PROVIDER_TYPES.WUZAPI" /> <Wuzapi v-else-if="selectedProvider === PROVIDER_TYPES.WUZAPI" />
<BaileysWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.BAILEYS"
/>
<ZapiWhatsapp v-else-if="selectedProvider === PROVIDER_TYPES.ZAPI" />
<!-- Other providers --> <!-- Other providers -->
<Twilio <Twilio

View File

@ -0,0 +1,182 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const inboxName = ref('');
const phoneNumber = ref('');
const instanceId = ref('');
const token = ref('');
const clientToken = ref('');
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
// NOTE: Affiliate link is left intentionally hardcoded.
const zapiAffiliateUrl =
'https://app.z-api.io/app/auth/new-account?afilliate=3E0B31343E6CB0297B567AC1D8277FBB';
const rules = computed(() => ({
inboxName: { required },
phoneNumber: { required, isPhoneE164OrEmpty },
instanceId: { required },
token: { required },
clientToken: { required },
}));
const v$ = useVuelidate(rules, {
inboxName,
phoneNumber,
instanceId,
token,
clientToken,
});
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
try {
const whatsappChannel = await store.dispatch('inboxes/createChannel', {
name: inboxName.value,
channel: {
type: 'whatsapp',
phone_number: phoneNumber.value,
provider: 'zapi',
provider_config: {
instance_id: instanceId.value,
token: token.value,
client_token: clientToken.value,
},
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: whatsappChannel.id,
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-full mb-6">
<PromoBanner
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.TITLE')"
:description="
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.DESCRIPTION')
"
variant="success"
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-green.png"
logo-alt="Z-API"
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.CTA')"
cta-external
:cta-link="zapiAffiliateUrl"
/>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
<input
v-model="inboxName"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="v$.inboxName.$touch"
/>
<span v-if="v$.inboxName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
<input
v-model="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
<span v-if="v$.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.instanceId.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.LABEL') }}
<input
v-model="instanceId"
type="password"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.PLACEHOLDER')"
@blur="v$.instanceId.$touch"
/>
<span v-if="v$.instanceId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.token.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.LABEL') }}
<input
v-model="token"
type="password"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.PLACEHOLDER')"
@blur="v$.token.$touch"
/>
<span v-if="v$.token.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.ERROR') }}
</span>
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<label :class="{ error: v$.clientToken.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.LABEL') }}
<input
v-model="clientToken"
type="password"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.PLACEHOLDER')"
@blur="v$.clientToken.$touch"
/>
<span v-if="v$.clientToken.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.ERROR') }}
</span>
</label>
</div>
<div class="w-full">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
/>
</div>
</form>
</template>

View File

@ -0,0 +1,7 @@
class Channels::Whatsapp::BaileysConnectionCheckJob < ApplicationJob
queue_as :low
def perform(whatsapp_channel)
whatsapp_channel.setup_channel_provider
end
end

View File

@ -0,0 +1,11 @@
class Channels::Whatsapp::BaileysConnectionCheckSchedulerJob < ApplicationJob
queue_as :low
def perform
Channel::Whatsapp.where(provider: 'baileys')
.where("provider_connection->>'connection' = ?", 'open')
.find_each do |channel|
Channels::Whatsapp::BaileysConnectionCheckJob.perform_later(channel)
end
end
end

View File

@ -0,0 +1,32 @@
class Channels::Whatsapp::ZapiQrCodeJob < ApplicationJob
queue_as :default
def perform(whatsapp_channel, attempt = 1)
return if attempt == 1 && whatsapp_channel.provider_connection.present? && whatsapp_channel.provider_connection['connection'] != 'close'
return if attempt > 1 && whatsapp_channel.provider_connection['connection'] != 'connecting'
if attempt > 3
whatsapp_channel.update_provider_connection!(connection: 'close')
return
end
fetch_and_update_qr_code(whatsapp_channel)
self.class.set(wait: 30.seconds).perform_later(whatsapp_channel, attempt + 1)
end
private
def fetch_and_update_qr_code(whatsapp_channel)
service = Whatsapp::Providers::WhatsappZapiService.new(whatsapp_channel: whatsapp_channel)
qr_code = service.qr_code_image
return if qr_code.blank?
# NOTE: Avoid race condition.
return if whatsapp_channel.reload.provider_connection['connection'] == 'open'
whatsapp_channel.update_provider_connection!(
connection: 'connecting',
qr_data_url: qr_code
)
end
end

View File

@ -0,0 +1,8 @@
class Channels::Whatsapp::ZapiReadMessageJob < ApplicationJob
queue_as :default
def perform(whatsapp_channel, phone, message_source_id)
service = Whatsapp::Providers::WhatsappZapiService.new(whatsapp_channel: whatsapp_channel)
service.send_read_message(phone, message_source_id)
end
end

View File

@ -0,0 +1,17 @@
module CrmInsights
class UpdateJob < ApplicationJob
queue_as :low
def perform(conversation_id, reason: nil)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
if reason == 'idle'
last_activity_at = conversation.last_activity_at
return if last_activity_at.present? && last_activity_at > 30.minutes.ago
end
UpdateService.new(conversation: conversation, reason: reason).call
end
end
end

View File

@ -25,8 +25,8 @@ class Channel::Whatsapp < ApplicationRecord
EDITABLE_ATTRS = [:phone_number, :provider, :wuzapi_user_token, :wuzapi_admin_token, { provider_config: {} }].freeze EDITABLE_ATTRS = [:phone_number, :provider, :wuzapi_user_token, :wuzapi_admin_token, { provider_config: {} }].freeze
# default at the moment is 360dialog lets change later. # default at the moment is 360dialog lets change later.
PROVIDERS = %w[default whatsapp_cloud wuzapi].freeze PROVIDERS = %w[default whatsapp_cloud wuzapi baileys zapi].freeze
encrypts :wuzapi_user_token, :wuzapi_admin_token encrypts :wuzapi_user_token, :wuzapi_admin_token
before_validation :ensure_webhook_verify_token before_validation :ensure_webhook_verify_token
@ -51,6 +51,10 @@ class Channel::Whatsapp < ApplicationRecord
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self) Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
when 'wuzapi' when 'wuzapi'
Whatsapp::Providers::WuzapiService.new(whatsapp_channel: self) Whatsapp::Providers::WuzapiService.new(whatsapp_channel: self)
when 'baileys'
Whatsapp::Providers::WhatsappBaileysService.new(whatsapp_channel: self)
when 'zapi'
Whatsapp::Providers::WhatsappZapiService.new(whatsapp_channel: self)
else else
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self) Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
end end
@ -63,6 +67,7 @@ class Channel::Whatsapp < ApplicationRecord
end end
delegate :send_message, to: :provider_service delegate :send_message, to: :provider_service
delegate :send_reaction_message, to: :provider_service
delegate :send_template, to: :provider_service delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service delegate :sync_templates, to: :provider_service
delegate :media_url, to: :provider_service delegate :media_url, to: :provider_service
@ -75,10 +80,85 @@ class Channel::Whatsapp < ApplicationRecord
prompt_reauthorization! prompt_reauthorization!
end end
def use_internal_host?
provider == 'baileys' && ENV.fetch('BAILEYS_PROVIDER_USE_INTERNAL_HOST_URL', false)
end
def update_provider_connection!(provider_connection)
assign_attributes(provider_connection: provider_connection)
# NOTE: Skip `validate_provider_config?` check
save!(validate: false)
end
def provider_connection_data
data = { connection: provider_connection['connection'] }
if Current.account_user&.administrator?
data[:qr_data_url] = provider_connection['qr_data_url']
data[:error] = provider_connection['error']
end
data
end
def toggle_typing_status(typing_status, conversation:)
return unless provider_service.respond_to?(:toggle_typing_status)
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
last_message = conversation.messages.last
provider_service.toggle_typing_status(typing_status, last_message: last_message, recipient_id: recipient_id)
end
def update_presence(status)
return unless provider_service.respond_to?(:update_presence)
provider_service.update_presence(status)
end
def read_messages(messages, conversation:)
return unless provider_service.respond_to?(:read_messages)
# NOTE: This is the default behavior, so `mark_as_read` being `nil` is the same as `true`.
return if provider_config&.dig('mark_as_read') == false
recipient_id = if provider == 'zapi'
conversation.contact.phone_number
else
conversation.contact.identifier || conversation.contact.phone_number
end
provider_service.read_messages(messages, recipient_id: recipient_id)
end
def unread_conversation(conversation)
return unless provider_service.respond_to?(:unread_message)
# NOTE: For the Baileys provider, the last message is required even if it is an outgoing message.
last_message = conversation.messages.last
provider_service.unread_message(conversation.contact.phone_number, last_message) if last_message
end
def disconnect_channel_provider
provider_service.disconnect_channel_provider
rescue StandardError => e
# NOTE: Don't prevent destruction if disconnect fails
Rails.logger.error "Failed to disconnect channel provider: #{e.message}"
end
def received_messages(messages, conversation)
return unless provider_service.respond_to?(:received_messages)
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
provider_service.received_messages(recipient_id, messages)
end
def on_whatsapp(phone_number)
return unless provider_service.respond_to?(:on_whatsapp)
provider_service.on_whatsapp(phone_number)
end
private private
def ensure_webhook_verify_token def ensure_webhook_verify_token
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider == 'whatsapp_cloud' provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider.in?(%w[whatsapp_cloud baileys])
end end
def move_tokens_to_encrypted_attributes def move_tokens_to_encrypted_attributes
@ -89,10 +169,10 @@ class Channel::Whatsapp < ApplicationRecord
provider_config.delete('wuzapi_user_token') provider_config.delete('wuzapi_user_token')
end end
if provider_config['wuzapi_admin_token'].present? return unless provider_config['wuzapi_admin_token'].present?
self.wuzapi_admin_token = provider_config['wuzapi_admin_token']
provider_config.delete('wuzapi_admin_token') self.wuzapi_admin_token = provider_config['wuzapi_admin_token']
end provider_config.delete('wuzapi_admin_token')
end end
def validate_provider_config def validate_provider_config
@ -120,6 +200,8 @@ class Channel::Whatsapp < ApplicationRecord
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Wuzapi Webhook Setup Failed: #{e.message}" Rails.logger.error "Wuzapi Webhook Setup Failed: #{e.message}"
end end
elsif provider_service.respond_to?(:setup_channel_provider)
provider_service.setup_channel_provider
else else
# 360Dialog / Cloud logic # 360Dialog / Cloud logic
business_account_id = provider_config['business_account_id'] business_account_id = provider_config['business_account_id']
@ -153,8 +235,8 @@ class Channel::Whatsapp < ApplicationRecord
rescue StandardError => e rescue StandardError => e
Rails.logger.warn "Wuzapi Provisioning failed with URL #{base_url}: #{e.message}" Rails.logger.warn "Wuzapi Provisioning failed with URL #{base_url}: #{e.message}"
# Fallback: if url ends in /api, strip it and try again # Fallback: if url ends in /api, strip it and try again
if base_url.match?(/\/api\/?$/) if base_url.match?(%r{/api/?$})
fallback_url = base_url.gsub(/\/api\/?$/, '') fallback_url = base_url.gsub(%r{/api/?$}, '')
Rails.logger.info "Retrying Wuzapi Provisioning with fallback URL: #{fallback_url}" Rails.logger.info "Retrying Wuzapi Provisioning with fallback URL: #{fallback_url}"
begin begin
result = attempt_provision.call(fallback_url) result = attempt_provision.call(fallback_url)
@ -175,7 +257,7 @@ class Channel::Whatsapp < ApplicationRecord
# Success handling # Success handling
provider_config['wuzapi_user_id'] = result[:wuzapi_user_id] provider_config['wuzapi_user_id'] = result[:wuzapi_user_id]
self.wuzapi_user_token = result[:wuzapi_user_token] self.wuzapi_user_token = result[:wuzapi_user_token]
masked_token = result[:wuzapi_user_token].to_s[-4..-1] masked_token = result[:wuzapi_user_token].to_s[-4..-1]
Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}" Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}"
end end

View File

@ -62,6 +62,7 @@ class Contact < ApplicationRecord
has_many :inboxes, through: :contact_inboxes has_many :inboxes, through: :contact_inboxes
has_many :messages, as: :sender, dependent: :destroy_async has_many :messages, as: :sender, dependent: :destroy_async
has_many :notes, dependent: :destroy_async has_many :notes, dependent: :destroy_async
has_many :crm_insights, class_name: 'ConversationCrmInsight', dependent: :destroy_async
before_validation :prepare_contact_attributes before_validation :prepare_contact_attributes
after_create_commit :dispatch_create_event, :ip_lookup after_create_commit :dispatch_create_event, :ip_lookup
after_update_commit :dispatch_update_event after_update_commit :dispatch_update_event

View File

@ -113,6 +113,7 @@ class Conversation < ApplicationRecord
has_many :notifications, as: :primary_actor, dependent: :destroy_async has_many :notifications, as: :primary_actor, dependent: :destroy_async
has_many :attachments, through: :messages has_many :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async has_many :reporting_events, dependent: :destroy_async
has_many :crm_insights, class_name: 'ConversationCrmInsight', dependent: :destroy
before_save :ensure_snooze_until_reset before_save :ensure_snooze_until_reset
before_create :determine_conversation_status before_create :determine_conversation_status
@ -211,6 +212,14 @@ class Conversation < ApplicationRecord
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes) dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
end end
def latest_crm_insight
crm_insights.success.order(generated_at: :desc).first
end
def latest_crm_insight_attempt
crm_insights.order(generated_at: :desc).first
end
private private
def execute_after_update_commit_callbacks def execute_after_update_commit_callbacks
@ -227,6 +236,8 @@ class Conversation < ApplicationRecord
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
update_column(:waiting_since, nil) update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations
CrmInsights::UpdateJob.perform_later(id, reason: 'resolved')
end end
def ensure_snooze_until_reset def ensure_snooze_until_reset

View File

@ -0,0 +1,10 @@
class ConversationCrmInsight < ApplicationRecord
belongs_to :conversation
belongs_to :contact
validates :conversation_id, presence: true
validates :contact_id, presence: true
scope :success, -> { where(status: 'success') }
scope :failed, -> { where(status: 'failed') }
end

View File

@ -315,12 +315,22 @@ class Message < ApplicationRecord
send_reply send_reply
execute_message_template_hooks execute_message_template_hooks
update_contact_activity update_contact_activity
schedule_crm_insights_update
end end
def update_contact_activity def update_contact_activity
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact) sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact)
end end
def schedule_crm_insights_update
return if private?
CrmInsights::UpdateJob.set(wait: 30.minutes).perform_later(
conversation_id,
reason: 'idle'
)
end
def update_waiting_since def update_waiting_since
if human_response? && !private && conversation.waiting_since.present? if human_response? && !private && conversation.waiting_since.present?
Rails.configuration.dispatcher.dispatch( Rails.configuration.dispatcher.dispatch(

View File

@ -0,0 +1,31 @@
module CrmInsights
class ContactSessionCounter
WINDOW = 24.hours
def initialize(conversation)
@conversation = conversation
end
def call
inbound_times = @conversation.messages
.where(message_type: :incoming, private: false)
.order(:created_at)
.pluck(:created_at)
count = 0
last_session_start = nil
inbound_times.each do |timestamp|
if last_session_start.nil? || timestamp > last_session_start + WINDOW
count += 1
last_session_start = timestamp
end
end
{
count: count,
last_contact_at: last_session_start
}
end
end
end

View File

@ -0,0 +1,152 @@
module CrmInsights
class GenerateService < Llm::BaseAiService
DEFAULT_MODEL = 'gpt-4o-mini'
def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil)
super()
@conversation = conversation
@insight = insight
@sessions_count = sessions_count
@last_contact_at = last_contact_at
@from_message_id = from_message_id
@to_message_id = to_message_id
@model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL)
end
def generate
chat = RubyLLM.chat(model: @model)
.with_temperature(0.2)
.with_params(response_format: { type: 'json_object' })
response = chat.ask(prompt)
parsed = parse_response(response)
return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank?
{ data: parsed, error: nil }
rescue StandardError => e
Rails.logger.error "[CRM Insights] Generation failed: #{e.message}"
{ data: nil, error: e.message }
end
private
def prompt
<<~PROMPT
Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente.
Regras:
- Idioma: PT-BR sempre.
- Nao resuma a conversa; gere um perfil do cliente.
- Frases curtas, estilo CRM humano.
- Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes.
- Atualize o resumo existente sem perder informacoes relevantes.
- Priorize padroes recorrentes sobre eventos isolados.
- Se dados forem insuficientes, diga que faltam sinais claros.
- So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo.
- Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio.
- Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio.
- Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio.
- Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos.
Saida OBRIGATORIA (JSON valido):
{
"summary_text": "texto humano completo para UI",
"structured_data": {
"summary_text": "...",
"preferences": [],
"contact_pattern": { "time_range": "", "days": [] },
"intent": "",
"price_sensitivity": "",
"urgency": "",
"frictions": [],
"commercial_status": "",
"customer_potential": "",
"agent_tip": ""
}
}
Contexto:
- Canal: #{channel_name}
- Conversa ID: #{@conversation.id}
- Contatos (24h): #{@sessions_count}
- Ultimo contato valido: #{format_time(@last_contact_at)}
- Intervalo de mensagens: #{message_range_label}
Resumo anterior (se existir):
#{@insight&.summary_text || 'Sem resumo anterior.'}
JSON anterior (se existir):
#{(@insight&.structured_data || {}).to_json}
Historico recente (ate 50 mensagens):
#{history_block}
Formato do texto humano (exemplo de estilo):
Cliente recorrente.
Demonstra preferencia por suites com hidro.
Costuma entrar em contato a noite (principalmente entre 19h e 23h).
Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in.
Perfil objetivo, poucas mensagens.
Intencao predominante: reserva rapida
Sensibilidade a preco: media
Urgencia: alta
Padrao de contato:
Horario: entre 19h e 23h
Dias mais comuns: sexta e sabado
Pontos de atencao:
Duvidas recorrentes sobre formas de pagamento
Questionamentos frequentes sobre horario de check-in
Status comercial atual: 🟢 Alta chance de conversao
Potencial do cliente:
Perfil recorrente
Compativel com suites premium
Bom candidato a fidelizacao
Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro.
PROMPT
end
def history_block
messages = @conversation.messages
.where(message_type: %i[incoming outgoing], private: false)
messages = messages.where('id >= ?', @from_message_id) if @from_message_id
messages = messages.where('id <= ?', @to_message_id) if @to_message_id
messages = messages.order(created_at: :desc).limit(50).reverse
messages.map do |message|
role = message.incoming? ? 'Cliente' : 'Atendente'
time = message.created_at&.strftime('%d/%m/%Y %H:%M')
"#{time} - #{role}: #{message.content}"
end.join("\n")
end
def channel_name
@conversation.inbox&.channel_type.to_s
end
def format_time(value)
return 'Desconhecido' if value.blank?
value.strftime('%d/%m/%Y %H:%M')
end
def parse_response(response)
content = response.respond_to?(:content) ? response.content : response.to_s
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}"
nil
end
def message_range_label
return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank?
return "A partir de #{@from_message_id}" if @to_message_id.blank?
return "Ate #{@to_message_id}" if @from_message_id.blank?
"#{@from_message_id} ate #{@to_message_id}"
end
end
end

View File

@ -0,0 +1,359 @@
module CrmInsights
class UpdateService
def initialize(conversation:, reason: nil)
@conversation = conversation
@reason = reason
end
def call
session_stats = ContactSessionCounter.new(@conversation).call
last_success = @conversation.latest_crm_insight
last_message_id = relevant_messages.maximum(:id)
return result_payload(last_success, 'no_messages') if last_message_id.blank?
from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil
to_message_id = last_message_id
return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id
result = GenerateService.new(
conversation: @conversation,
insight: last_success,
sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
from_message_id: from_message_id,
to_message_id: to_message_id
).generate
if result[:data].blank?
create_failed_insight(
session_stats: session_stats,
from_message_id: from_message_id,
to_message_id: to_message_id,
error_message: result[:error] || 'Falha ao gerar resumo'
)
return result_payload(last_success, 'failed', result[:error])
end
range_messages = messages_for_range(from_message_id, to_message_id)
sanitized_result = sanitize_result(
result[:data],
range_messages,
last_success&.structured_data || {},
@conversation.contact
)
insight = create_success_insight(
result: sanitized_result,
session_stats: session_stats,
from_message_id: from_message_id,
to_message_id: to_message_id
)
result_payload(insight, 'success')
end
private
def relevant_messages
@relevant_messages ||= @conversation.messages.where(
message_type: %i[incoming outgoing],
private: false
)
end
def messages_for_range(from_message_id, to_message_id)
scope = relevant_messages
scope = scope.where('id >= ?', from_message_id) if from_message_id
scope = scope.where('id <= ?', to_message_id) if to_message_id
scope
end
def sanitize_result(result, messages, prior_structured, contact)
structured_data = result['structured_data'] || {}
incoming_messages = messages.select(&:incoming?)
incoming_text = incoming_messages.map { |message| message.content.to_s.downcase }.join(' ')
inbound_count = messages.count(&:incoming?)
outbound_count = messages.count(&:outgoing?)
sanitized_structured = structured_data.deep_dup
return minimal_payload(incoming_messages, contact) if inbound_count < 3
sanitized_structured['frictions'] = sanitize_frictions(
structured_data['frictions'],
incoming_text,
prior_structured['frictions']
)
sanitized_structured['contact_pattern'] = sanitize_contact_pattern(
structured_data['contact_pattern'],
incoming_text,
inbound_count,
prior_structured['contact_pattern']
)
sanitized_structured['preferences'] = sanitize_preferences(
structured_data['preferences'],
incoming_text,
prior_structured['preferences']
)
if inbound_count < 3 && outbound_count < 3
sanitized_structured['intent'] = ''
sanitized_structured['urgency'] = ''
sanitized_structured['price_sensitivity'] = ''
sanitized_structured['commercial_status'] = ''
sanitized_structured['customer_potential'] = ''
end
summary_text = result['summary_text'].to_s.strip
summary_text = summary_text.presence || 'Ainda nao ha dados suficientes para um perfil do cliente.'
sanitized_structured['summary_text'] = summary_text
sanitized_structured['schema_version'] = structured_data['schema_version'] || '1.0'
sanitized_structured['source'] = structured_data['source'] || 'ai'
sanitized_structured['generated_at'] = structured_data['generated_at'] || Time.current.iso8601
sanitized_structured['evidence'] ||= {}
{
'summary_text' => summary_text,
'structured_data' => sanitized_structured
}
end
def sanitize_frictions(frictions, text, prior_frictions)
items = Array(frictions).map(&:to_s)
return Array(prior_frictions).map(&:to_s) if items.empty?
evidence = {
'pagamento' => /(pagamento|pix|cart[aã]o|forma de pagamento)/i,
'checkin' => /(check-?in|entrada|hor[aá]rio de entrada)/i,
'preco' => /(pre[cç]o|valor|custo)/i
}
filtered = items.select do |item|
evidence.any? { |key, pattern| item.downcase.include?(key) && text.match?(pattern) } ||
evidence.any? { |_, pattern| text.match?(pattern) && item.downcase.match?(pattern) }
end
return Array(prior_frictions).map(&:to_s) if filtered.empty? && prior_frictions.present?
filtered
end
def sanitize_contact_pattern(pattern, text, inbound_count, prior_pattern)
pattern_hash = pattern.is_a?(Hash) ? pattern : {}
time_range = pattern_hash['time_range'].to_s
days = Array(pattern_hash['days']).map(&:to_s)
if inbound_count < 3
return prior_pattern if prior_pattern.present?
return { 'time_range' => '', 'days' => [] }
end
time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i)
day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
time_range = '' unless time_evidence
days = [] unless day_evidence
if days.any?
normalized_text = text.downcase
days = days.select do |day|
normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i)
end
end
{
'time_range' => time_range,
'days' => days
}
end
def sanitize_preferences(preferences, text, prior_preferences)
return Array(prior_preferences).map(&:to_s) if preferences.blank?
tokens = if preferences.is_a?(Array)
preferences
elsif preferences.is_a?(Hash)
preferences.values.flatten
else
[preferences]
end
filtered = tokens.map(&:to_s).select do |item|
case item.downcase
when /hidro/
text.include?('hidro')
when /pix/
text.include?('pix')
when /check/
text.match?(/check-?in/)
else
parts = item.downcase.split(/[_\s]/).reject(&:blank?)
parts.any? { |part| text.include?(part) }
end
end
return Array(prior_preferences).map(&:to_s) if filtered.empty? && prior_preferences.present?
filtered
end
def minimal_summary(text, preferences)
prefs = Array(preferences).map(&:to_s).reject(&:blank?)
parts = []
if prefs.any?
humanized = prefs.map { |item| item.tr('_', ' ') }
parts << "demonstrou interesse em #{humanized.join(', ')}"
end
parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i)
parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i)
parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
return 'Conversa inicial, sem historico suficiente para inferir padroes.' if parts.empty?
"Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes."
end
def minimal_payload(incoming_messages, contact)
incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ')
normalized_text = normalize_text(incoming_text)
evidence = {}
preferred_name = contact&.additional_attributes&.fetch('preferred_name', nil)
if preferred_name.present?
name_ids = evidence_ids_for(preferred_name, incoming_messages)
evidence['preferred_name'] = name_ids if name_ids.any?
end
room_type = nil
if normalized_text.include?('hidro')
room_type = 'suite_hidro'
evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages)
end
day_interest = []
day_map.each_key do |day|
day_interest << day if normalized_text.match?(/\b#{day}\b/i)
end
if day_interest.any?
day_regex = Regexp.union(day_interest.map { |day| /\b#{day}\b/i })
evidence['preferences.date_interest'] = evidence_ids_for(day_regex, incoming_messages)
end
intent = nil
if normalized_text.match?(/reserv|disponibil|vaga|quero|gostaria/)
intent = 'reserva_rapida'
evidence['intent'] = evidence_ids_for(/reserv|disponibil|vaga|quero|gostaria/i, incoming_messages)
end
summary_text = minimal_summary(normalized_text, room_type ? [room_type] : [])
summary_text = "Cliente se apresentou como #{preferred_name}. #{summary_text}" if preferred_name.present?
summary_text = summary_text.strip
structured_data = {
'schema_version' => '1.0',
'source' => 'ai',
'generated_at' => Time.current.iso8601,
'summary_text' => summary_text,
'customer_type' => nil,
'customer_potential' => nil,
'intent' => intent,
'urgency' => nil,
'price_sensitivity' => nil,
'confidence' => intent.present? ? 0.9 : nil,
'preferences' => {
'room_type' => room_type ? [room_type] : [],
'date_interest' => day_interest
},
'contact_pattern' => nil,
'frictions' => nil,
'commercial_status' => nil,
'nba' => if intent.present?
{
'action' => 'informar_disponibilidade_e_valor',
'priority' => 'media',
'reason' => 'Cliente demonstrou interesse inicial, mas ainda nao informou horario nem forma de pagamento.'
}
end,
'suggested_labels' => [
(room_type ? 'hidro' : nil),
'primeiro_contato'
].compact,
'evidence' => evidence
}
{
'summary_text' => summary_text,
'structured_data' => structured_data
}
end
def evidence_ids_for(pattern, messages)
regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i
messages.select { |message| message.content.to_s.match?(regex) }.map(&:id)
end
def normalize_text(value)
value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc')
end
def day_map
{
'segunda' => 'segunda',
'terca' => 'terca',
'quarta' => 'quarta',
'quinta' => 'quinta',
'sexta' => 'sexta',
'sabado' => 'sabado',
'domingo' => 'domingo'
}
end
def create_success_insight(result:, session_stats:, from_message_id:, to_message_id:)
structured_data = result['structured_data'] || {}
model_name = ENV.fetch('CRM_INSIGHTS_MODEL', CrmInsights::GenerateService::DEFAULT_MODEL)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: result['summary_text'],
structured_data: structured_data,
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'success',
schema_version: structured_data['schema_version'] || '1.0',
model: structured_data['model'] || model_name,
confidence: structured_data['confidence']
)
end
def create_failed_insight(session_stats:, from_message_id:, to_message_id:, error_message:)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: nil,
structured_data: {},
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'failed',
error_message: error_message
)
end
def result_payload(insight, status, error_message = nil)
{
insight: insight,
status: status,
error_message: error_message
}
end
end
end

View File

@ -0,0 +1,22 @@
module Whatsapp::BaileysHandlers::ConnectionUpdate
include Whatsapp::BaileysHandlers::Helpers
private
def process_connection_update
data = processed_params[:data]
# NOTE: `connection` values
# - `close`: Never opened, or closed and no longer able to send/receive messages
# - `connecting`: In the process of connecting, expecting QR code to be read
# - `reconnecting`: Connection has been established, but not open (i.e. device is being linked for the first time, or Baileys server restart)
# - `open`: Open and ready to send/receive messages
inbox.channel.update_provider_connection!({
connection: data[:connection] || inbox.channel.provider_connection['connection'],
qr_data_url: data[:qrDataUrl] || nil,
error: data[:error] ? I18n.t("errors.inboxes.channel.provider_connection.#{data[:error]}") : nil
}.compact)
Rails.logger.error "Baileys connection error: #{data[:error]}" if data[:error].present?
end
end

View File

@ -0,0 +1,209 @@
module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
include Whatsapp::IncomingMessageServiceHelpers
private
def unwrap_ephemeral_message(msg)
msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg
end
def raw_message_id
@raw_message[:key][:id]
end
def incoming?
!@raw_message[:key][:fromMe]
end
def jid_type # rubocop:disable Metrics/CyclomaticComplexity
jid = @raw_message[:key][:remoteJid]
server = jid.split('@').last
# NOTE: Based on Baileys internal functions
# https://github.com/WhiskeySockets/Baileys/blob/v6.7.16/src/WABinary/jid-utils.ts#L48-L58
case server
when 's.whatsapp.net', 'c.us'
'user'
when 'g.us'
'group'
when 'lid'
'lid'
when 'broadcast'
jid.start_with?('status@') ? 'status' : 'broadcast'
when 'newsletter'
'newsletter'
when 'call'
'call'
else
'unknown'
end
end
def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength,Metrics/AbcSize
msg = unwrap_ephemeral_message(@raw_message[:message])
if msg.key?(:conversation) || msg.dig(:extendedTextMessage, :text).present?
'text'
elsif msg.key?(:imageMessage)
'image'
elsif msg.key?(:audioMessage)
'audio'
elsif msg.key?(:videoMessage)
'video'
elsif msg.key?(:documentMessage) || msg.key?(:documentWithCaptionMessage)
'file'
elsif msg.key?(:stickerMessage)
'sticker'
elsif msg.key?(:reactionMessage)
'reaction'
elsif msg.key?(:editedMessage)
'edited'
elsif msg.key?(:contactMessage)
match_phone_number = msg.dig(:contactMessage, :vcard)&.match(/waid=(\d+)/)
match_phone_number ? 'contact' : 'unsupported'
elsif msg.key?(:protocolMessage)
'protocol'
elsif msg.key?(:messageContextInfo) && msg.keys.count == 1
'context'
else
'unsupported'
end
end
def message_content # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
msg = unwrap_ephemeral_message(@raw_message[:message])
case message_type
when 'text'
msg[:conversation] || msg.dig(:extendedTextMessage, :text)
when 'image'
msg.dig(:imageMessage, :caption)
when 'video'
msg.dig(:videoMessage, :caption)
when 'file'
msg.dig(:documentMessage, :caption).presence ||
msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption)
when 'reaction'
msg.dig(:reactionMessage, :text)
when 'contact'
# FIXME: Missing specs
display_name = msg.dig(:contactMessage, :displayName)
vcard = msg.dig(:contactMessage, :vcard)
match_phone_number = vcard&.match(/waid=(\d+)/)
return display_name unless match_phone_number
return match_phone_number[1] if display_name&.start_with?('+')
"#{display_name} - #{match_phone_number[1]}" if match_phone_number
end
end
def reply_to_message_id # rubocop:disable Metrics/CyclomaticComplexity
msg = unwrap_ephemeral_message(@raw_message[:message])
message_key = case message_type
when 'text' then :extendedTextMessage
when 'image' then :imageMessage
when 'sticker' then :stickerMessage
when 'audio' then :audioMessage
when 'video' then :videoMessage
when 'contact' then :contactMessage
when 'file'
context_info = msg.dig(:documentMessage, :contextInfo).presence ||
msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
return context_info&.dig(:stanzaId)
end
msg.dig(message_key, :contextInfo, :stanzaId) if message_key
end
def file_content_type
return :image if message_type.in?(%w[image sticker])
return :video if message_type.in?(%w[video video_note])
return :audio if message_type == 'audio'
:file
end
def message_mimetype
msg = unwrap_ephemeral_message(@raw_message[:message])
case message_type
when 'image'
msg.dig(:imageMessage, :mimetype)
when 'sticker'
msg.dig(:stickerMessage, :mimetype)
when 'video'
msg.dig(:videoMessage, :mimetype)
when 'audio'
msg.dig(:audioMessage, :mimetype)
when 'file'
msg.dig(:documentMessage, :mimetype).presence ||
msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :mimetype)
end
end
def extract_from_jid(type:)
addressing_mode = @raw_message[:key][:addressingMode]
reference_field = addressing_mode && addressing_mode != type ? :remoteJidAlt : :remoteJid
jid = @raw_message[:key][reference_field]
return unless jid
# NOTE: jid shape is `<user>_<agent>:<device>@<server>`
# https://github.com/WhiskeySockets/Baileys/blob/v7.0.0-rc.6/src/WABinary/jid-utils.ts#L52
jid.split('@').first.split(':').first.split('_').first
end
def contact_name
# NOTE: `verifiedBizName` is only available for business accounts and has a higher priority than `pushName`.
name = @raw_message[:verifiedBizName].presence || @raw_message[:pushName]
return name if name.presence && (self_message? || incoming?)
extract_from_jid(type: 'pn') || extract_from_jid(type: 'lid')
end
def self_message?
normalize_phone_number(extract_from_jid(type: 'pn')) == normalize_phone_number(inbox.channel.phone_number.delete('+'))
end
def normalize_phone_number(phone_number)
return unless phone_number
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer.new.normalize(phone_number)
end
def ignore_message?
message_type.in?(%w[protocol context edited]) ||
(message_type == 'reaction' && message_content.blank?)
end
def fetch_profile_picture_url(phone_number)
jid = "#{phone_number}@s.whatsapp.net"
response = inbox.channel.provider_service.get_profile_pic(jid)
response&.dig('data', 'profilePictureUrl')
rescue StandardError => e
Rails.logger.error "Failed to fetch profile picture for #{phone_number}: #{e.message}"
nil
end
def try_update_contact_avatar
# TODO: Current logic will never update the contact avatar if their profile picture changes on WhatsApp.
return if @contact.avatar.attached?
phone = extract_from_jid(type: 'pn')
profile_pic_url = fetch_profile_picture_url(phone) if phone
::Avatar::AvatarFromUrlJob.perform_later(@contact, profile_pic_url) if profile_pic_url
end
def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
Redis::Alfred.get(key)
end
def cache_message_source_id_in_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
::Redis::Alfred.setex(key, true)
end
def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
::Redis::Alfred.delete(key)
end
end

View File

@ -0,0 +1,86 @@
module Whatsapp::BaileysHandlers::MessagesUpdate
include Whatsapp::BaileysHandlers::Helpers
class MessageNotFoundError < StandardError; end
private
def process_messages_update
updates = processed_params[:data]
updates.each do |update|
@message = nil
@raw_message = update
next handle_update if incoming?
# NOTE: Shared lock with Whatsapp::SendOnWhatsappService
# Avoids race conditions when sending messages.
with_baileys_channel_lock_on_outgoing_message(inbox.channel.id) { handle_update }
end
end
def handle_update
raise MessageNotFoundError unless find_message_by_source_id(raw_message_id)
update_status if @raw_message.dig(:update, :status).present?
handle_edited_content if @raw_message.dig(:update, :message).present?
end
def update_status
status = status_mapper
update_last_seen_at if incoming? && status == 'read'
@message.update!(status: status) if status.present? && status_transition_allowed?(status)
end
def status_mapper
# NOTE: Baileys status codes vs. Chatwoot support:
# - (0) ERROR → (3) failed
# - (1) PENDING → (0) sent
# - (2) SERVER_ACK → (0) sent
# - (3) DELIVERY_ACK → (1) delivered
# - (4) READ → (2) read
# - (5) PLAYED → (unsupported: PLAYED)
# For details: https://github.com/WhiskeySockets/Baileys/blob/v6.7.16/WAProto/index.d.ts#L36694
status = @raw_message.dig(:update, :status)
case status
when 0
'failed'
when 1, 2
'sent'
when 3
'delivered'
when 4
'read'
when 5
Rails.logger.warn 'Baileys unsupported message update status: PLAYED(5)'
nil
else
Rails.logger.warn "Baileys unsupported message update status: #{status}"
nil
end
end
def update_last_seen_at
conversation = @message.conversation
to_update = { agent_last_seen_at: Time.current }
to_update[:assignee_last_seen_at] = Time.current if conversation.assignee_id.present?
conversation.update_columns(to_update) # rubocop:disable Rails/SkipsModelValidations
end
def status_transition_allowed?(new_status)
return false if @message.status == 'read'
return false if @message.status == 'delivered' && new_status == 'sent'
true
end
def handle_edited_content
@raw_message = @raw_message.dig(:update, :message, :editedMessage)
content = message_content
return @message.update!(content: content, is_edited: true, previous_content: @message.content) if content
Rails.logger.warn 'No valid message content found in the edit event'
end
end

View File

@ -0,0 +1,160 @@
module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/ModuleLength
include Whatsapp::BaileysHandlers::Helpers
include BaileysHelper
private
def process_messages_upsert
messages = processed_params[:data][:messages]
messages.each do |message|
@message = nil
@contact_inbox = nil
@contact = nil
@raw_message = message
next handle_message if incoming?
# NOTE: Shared lock with Whatsapp::SendOnWhatsappService
# Avoids race conditions when sending messages.
with_baileys_channel_lock_on_outgoing_message(inbox.channel.id) { handle_message }
end
end
def handle_message
return unless %w[lid user].include?(jid_type)
return unless extract_from_jid(type: 'lid')
return if ignore_message?
return if find_message_by_source_id(raw_message_id) || message_under_process?
cache_message_source_id_in_redis
set_contact
unless @contact
clear_message_source_id_from_redis
Rails.logger.warn "Contact not found for message: #{raw_message_id}"
return
end
set_conversation
handle_create_message
clear_message_source_id_from_redis
end
def set_contact
phone = extract_from_jid(type: 'pn')
source_id = extract_from_jid(type: 'lid')
identifier = "#{source_id}@lid"
update_existing_contact_inbox(phone, source_id, identifier) if phone
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: inbox,
contact_attributes: { name: contact_name, phone_number: ("+#{phone}" if phone), identifier: identifier }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
update_contact_info(phone, source_id, identifier)
end
def update_existing_contact_inbox(phone, source_id, identifier)
# NOTE: This is useful when we create a new contact manually, so we don't have information about contact LID;
# With this, when we receive a message from that contact, we can link it properly.
existing_contact_inbox = inbox.contact_inboxes.find_by(source_id: phone)
return unless existing_contact_inbox
return if inbox.contact_inboxes.exists?(source_id: source_id)
existing_contact = existing_contact_inbox.contact
conflicting_identifier = inbox.account.contacts.find_by(identifier: identifier)
conflicting_phone = inbox.account.contacts.find_by(phone_number: "+#{phone}")
return if conflicting_identifier && conflicting_identifier.id != existing_contact.id
return if conflicting_phone && conflicting_phone.id != existing_contact.id
ActiveRecord::Base.transaction do
existing_contact_inbox.update!(source_id: source_id)
existing_contact.update!(identifier: identifier, phone_number: "+#{phone}")
end
end
def update_contact_info(phone, source_id, identifier)
update_params = {}
update_params[:phone_number] = "+#{phone}" if phone
update_params[:identifier] = identifier
update_params[:name] = contact_name if @contact.name.in?([phone, source_id, identifier])
@contact.update!(update_params) if update_params.present?
try_update_contact_avatar
end
def handle_create_message
create_message(attach_media: %w[image file video audio sticker].include?(message_type))
end
def create_message(attach_media: false)
@message = @conversation.messages.build(
content: message_content,
account_id: @inbox.account_id,
inbox_id: @inbox.id,
source_id: raw_message_id,
sender: incoming? ? @contact : @inbox.account.account_users.first.user,
sender_type: incoming? ? 'Contact' : 'User',
message_type: incoming? ? :incoming : :outgoing,
content_attributes: message_content_attributes
)
handle_attach_media if attach_media
@message.save!
inbox.channel.received_messages([@message], @conversation) if incoming?
end
def message_content_attributes
type = message_type
msg = unwrap_ephemeral_message(@raw_message[:message])
content_attributes = { external_created_at: baileys_extract_message_timestamp(@raw_message[:messageTimestamp]) }
if type == 'reaction'
content_attributes[:in_reply_to_external_id] = msg.dig(:reactionMessage, :key, :id)
content_attributes[:is_reaction] = true
elsif reply_to_message_id
content_attributes[:in_reply_to_external_id] = reply_to_message_id
elsif type == 'unsupported'
content_attributes[:is_unsupported] = true
end
content_attributes
end
def handle_attach_media
attachment_file = download_attachment_file
msg = unwrap_ephemeral_message(@raw_message[:message])
attachment = @message.attachments.build(
account_id: @message.account_id,
file_type: file_content_type.to_s,
file: { io: attachment_file, filename: filename, content_type: message_mimetype }
)
attachment.meta = { is_recorded_audio: true } if msg.dig(:audioMessage, :ptt)
rescue Down::Error => e
@message.update!(is_unsupported: true)
Rails.logger.error "Failed to download attachment for message #{raw_message_id}: #{e.message}"
end
def download_attachment_file
Down.download(@conversation.inbox.channel.media_url(@raw_message.dig(:key, :id)), headers: @conversation.inbox.channel.api_headers)
end
def filename
msg = unwrap_ephemeral_message(@raw_message[:message])
filename = msg.dig(:documentMessage, :fileName) || msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :fileName)
return filename if filename.present?
ext = ".#{message_mimetype.split(';').first.split('/').last}" if message_mimetype.present?
"#{file_content_type}_#{raw_message_id}_#{Time.current.strftime('%Y%m%d')}#{ext}"
end
end

View File

@ -0,0 +1,23 @@
class Whatsapp::IncomingMessageZapiService < Whatsapp::IncomingMessageBaseService
include Events::Types
include Whatsapp::ZapiHandlers::ConnectedCallback
include Whatsapp::ZapiHandlers::DisconnectedCallback
include Whatsapp::ZapiHandlers::ReceivedCallback
include Whatsapp::ZapiHandlers::DeliveryCallback
include Whatsapp::ZapiHandlers::MessageStatusCallback
def perform
return if processed_params[:type].blank?
Rails.configuration.dispatcher.dispatch(PROVIDER_EVENT_RECEIVED, Time.zone.now, inbox: inbox, event: processed_params[:type],
payload: processed_params)
event_prefix = processed_params[:type].underscore
method_name = "process_#{event_prefix}"
if respond_to?(method_name, true)
send(method_name)
else
Rails.logger.warn "Z-API unsupported event: #{processed_params.inspect}"
end
end
end

View File

@ -0,0 +1,376 @@
class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseService # rubocop:disable Metrics/ClassLength
include BaileysHelper
class MessageContentTypeNotSupported < StandardError; end
class ProviderUnavailableError < StandardError; end
DEFAULT_CLIENT_NAME = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME', nil)
DEFAULT_URL = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_URL', nil)
DEFAULT_API_KEY = ENV.fetch('BAILEYS_PROVIDER_DEFAULT_API_KEY', nil)
def self.status
if DEFAULT_URL.blank? || DEFAULT_API_KEY.blank?
raise ProviderUnavailableError, 'Missing BAILEYS_PROVIDER_DEFAULT_URL or BAILEYS_PROVIDER_DEFAULT_API_KEY setup'
end
response = HTTParty.get(
"#{DEFAULT_URL}/status",
headers: { 'x-api-key' => DEFAULT_API_KEY }
)
unless response.success?
Rails.logger.error response.body
raise ProviderUnavailableError, 'Baileys API is unavailable'
end
response.parsed_response.deep_symbolize_keys
rescue ProviderUnavailableError
raise
rescue StandardError => e
Rails.logger.error e.message
raise ProviderUnavailableError, 'Baileys API is unavailable'
end
def setup_channel_provider
response = HTTParty.post(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}",
headers: api_headers,
body: {
clientName: DEFAULT_CLIENT_NAME,
webhookUrl: whatsapp_channel.inbox.callback_webhook_url,
webhookVerifyToken: whatsapp_channel.provider_config['webhook_verify_token'],
# TODO: Remove on Baileys v2, default will be false
includeMedia: false
}.compact.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
def disconnect_channel_provider
response = HTTParty.delete(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}",
headers: api_headers
)
raise ProviderUnavailableError unless process_response(response)
true
end
true
end
def send_message(recipient_id, message)
@message = message
@recipient_id = recipient_id
if @message.content_attributes[:is_reaction]
@message_content = reaction_message_content
elsif @message.attachments.present?
@message_content = attachment_message_content
elsif @message.outgoing_content.present?
@message_content = { text: @message.outgoing_content }
else
@message.update!(is_unsupported: true)
return
end
send_message_request
end
def send_reaction_message(recipient_id, message)
@message = message
@recipient_id = recipient_id
@message_content = reaction_message_content
send_message_request
end
def send_template(phone_number, template_info); end
def sync_templates; end
def media_url(media_id)
"#{provider_url}/media/#{media_id}"
end
def api_headers
{ 'x-api-key' => api_key, 'Content-Type' => 'application/json' }
end
def validate_provider_config?
response = HTTParty.get(
"#{provider_url}/status/auth",
headers: api_headers
)
process_response(response)
end
def toggle_typing_status(typing_status, recipient_id:, **)
@recipient_id = recipient_id
status_map = {
Events::Types::CONVERSATION_TYPING_ON => 'composing',
Events::Types::CONVERSATION_RECORDING => 'recording',
Events::Types::CONVERSATION_TYPING_OFF => 'paused'
}
response = HTTParty.patch(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/presence",
headers: api_headers,
body: {
toJid: remote_jid,
type: status_map[typing_status]
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
def update_presence(status)
status_map = {
'online' => 'available',
'offline' => 'unavailable',
'busy' => 'unavailable'
}
response = HTTParty.patch(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/presence",
headers: api_headers,
body: {
type: status_map[status]
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
def read_messages(messages, recipient_id:, **)
@recipient_id = recipient_id
response = HTTParty.post(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/read-messages",
headers: api_headers,
body: {
keys: messages.map do |message|
{
id: message.source_id,
remoteJid: remote_jid,
fromMe: message.message_type == 'outgoing'
}
end
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
def unread_message(recipient_id, message) # rubocop:disable Metrics/MethodLength
@recipient_id = recipient_id
response = HTTParty.post(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/chat-modify",
headers: api_headers,
body: {
jid: remote_jid,
mod: {
markRead: false,
lastMessages: [{
key: {
id: message.source_id,
remoteJid: remote_jid,
fromMe: message.message_type == 'outgoing'
},
messageTimestamp: message.content_attributes[:external_created_at]
}]
}
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
def received_messages(recipient_id, messages)
@recipient_id = recipient_id
response = HTTParty.post(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/send-receipts",
headers: api_headers,
body: {
keys: messages.map do |message|
{
id: message.source_id,
remoteJid: remote_jid,
fromMe: message.message_type == 'outgoing'
}
end
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
def get_profile_pic(jid)
response = HTTParty.get(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/profile-picture-url",
headers: api_headers,
query: { jid: jid },
format: :json
)
return nil unless process_response(response)
response.parsed_response
end
def on_whatsapp(recipient_id)
@recipient_id = recipient_id
response = HTTParty.post(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/on-whatsapp",
headers: api_headers,
body: {
jids: [remote_jid]
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
response.parsed_response&.first || { 'jid' => remote_jid, 'exists' => false }
end
private
def provider_url
whatsapp_channel.provider_config['provider_url'].presence || DEFAULT_URL
end
def api_key
whatsapp_channel.provider_config['api_key'].presence || DEFAULT_API_KEY
end
def reaction_message_content
reply_to = Message.find(@message.in_reply_to)
{
react: { key: { id: reply_to.source_id,
remoteJid: remote_jid,
fromMe: reply_to.message_type == 'outgoing' },
text: @message.outgoing_content }
}
end
def attachment_message_content # rubocop:disable Metrics/MethodLength
attachment = @message.attachments.first
buffer = attachment_to_base64(attachment)
content = {
fileName: attachment.file.filename,
caption: @message.outgoing_content
}
case attachment.file_type
when 'image'
content[:image] = buffer
when 'audio'
content[:audio] = buffer
content[:ptt] = attachment.meta&.dig('is_recorded_audio')
when 'file'
content[:document] = buffer
content[:mimetype] = attachment.file.content_type
when 'sticker'
content[:sticker] = buffer
when 'video'
content[:video] = buffer
end
content.compact
end
def send_message_request
response = HTTParty.post(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/send-message",
headers: api_headers,
body: {
jid: remote_jid,
messageContent: @message_content
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
update_external_created_at(response)
response.parsed_response.dig('data', 'key', 'id')
end
def process_response(response)
Rails.logger.error response.body unless response.success?
response.success?
end
def remote_jid
return @recipient_id if @recipient_id.ends_with?('@lid')
"#{@recipient_id.delete('+')}@s.whatsapp.net"
end
def update_external_created_at(response)
timestamp = response.parsed_response.dig('data', 'messageTimestamp')
return unless timestamp
external_created_at = baileys_extract_message_timestamp(timestamp)
@message.update!(external_created_at: external_created_at)
end
private_class_method def self.with_error_handling(*method_names)
method_names.each do |method_name|
original_method = instance_method(method_name)
define_method("#{method_name}_without_error_handling") do |*args, **kwargs, &block|
original_method.bind_call(self, *args, **kwargs, &block)
end
define_method(method_name) do |*args, **kwargs, &block|
original_method.bind_call(self, *args, **kwargs, &block)
rescue StandardError => e
handle_channel_error
raise e
end
end
end
def handle_channel_error
whatsapp_channel.update_provider_connection!(connection: 'close')
return if @handling_error
@handling_error = true
begin
setup_channel_provider_without_error_handling
rescue StandardError => e
Rails.logger.error "Failed to reconnect channel after error: #{e.message}"
ensure
@handling_error = false
end
end
with_error_handling :setup_channel_provider,
:disconnect_channel_provider,
:send_message,
:toggle_typing_status,
:update_presence,
:read_messages,
:unread_message,
:received_messages,
:on_whatsapp
end

View File

@ -0,0 +1,282 @@
class Whatsapp::Providers::WhatsappZapiService < Whatsapp::Providers::BaseService # rubocop:disable Metrics/ClassLength
class ProviderUnavailableError < StandardError; end
API_BASE_PATH = 'https://api.z-api.io'.freeze
def send_template(phone_number, template_info); end
def sync_templates; end
def send_message(phone, message)
phone = phone.delete('+')
params = message.content_attributes[:zapi_args].presence || {}
params[:messageId] = message.in_reply_to_external_id if message.in_reply_to_external_id.present?
if message.content_attributes[:is_reaction]
send_reaction_message(phone, message, **params)
elsif message.attachments.present?
handle_message_with_attachment(message, phone, **params)
elsif message.outgoing_content.present?
send_text_message(phone, message, **params)
else
message.update!(is_unsupported: true)
nil
end
end
def validate_provider_config?
response = HTTParty.get(
"#{api_instance_path_with_token}/status",
headers: api_headers
)
process_response(response)
end
def setup_channel_provider
response = HTTParty.put(
"#{api_instance_path_with_token}/update-every-webhooks",
headers: api_headers,
body: {
value: whatsapp_channel.inbox.callback_webhook_url,
notifySentByMe: true
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
if whatsapp_channel.provider_connection.blank? || whatsapp_channel.provider_connection['connection'] == 'close'
Channels::Whatsapp::ZapiQrCodeJob.perform_later(whatsapp_channel)
end
true
end
def disconnect_channel_provider
response = HTTParty.get(
"#{api_instance_path_with_token}/disconnect",
headers: api_headers
)
raise ProviderUnavailableError unless process_response(response)
true
end
def qr_code_image
response = HTTParty.get(
"#{api_instance_path_with_token}/qr-code/image",
headers: api_headers
)
if response.parsed_response['connected']
whatsapp_channel.update_provider_connection!(connection: 'open')
return
end
return unless process_response(response)
response.parsed_response['value']
end
def read_messages(messages, recipient_id:, **)
phone = recipient_id.delete('+')
messages.each do |message|
next if message.source_id.blank?
Channels::Whatsapp::ZapiReadMessageJob.perform_later(whatsapp_channel, phone, message.source_id)
end
true
end
def send_read_message(phone, message_source_id)
response = HTTParty.post(
"#{api_instance_path_with_token}/read-message",
headers: api_headers,
body: {
phone: phone,
messageId: message_source_id
}.to_json
)
process_response(response)
end
def on_whatsapp(phone_number)
response = HTTParty.get(
"#{api_instance_path_with_token}/phone-exists/#{phone_number.delete('+')}",
headers: api_headers
)
raise ProviderUnavailableError unless process_response(response)
response.parsed_response || { 'exists' => false, 'phone' => nil, 'lid' => nil }
end
private
def api_instance_path
"#{API_BASE_PATH}/instances/#{whatsapp_channel.provider_config['instance_id']}"
end
def api_instance_path_with_token
"#{api_instance_path}/token/#{whatsapp_channel.provider_config['token']}"
end
def api_headers
{ 'Content-Type' => 'application/json', 'Client-Token' => whatsapp_channel.provider_config['client_token'] }
end
def process_response(response)
Rails.logger.error response.body unless response.success?
response.success?
end
def send_text_message(phone, message, **params)
response = HTTParty.post(
"#{api_instance_path_with_token}/send-text",
headers: api_headers,
body: {
phone: phone,
message: message.outgoing_content,
**params
}.compact.to_json
)
unless process_response(response)
message.update!(status: :failed, external_error: response.parsed_response&.dig('error'))
raise ProviderUnavailableError
end
response.parsed_response&.dig('messageId')
end
def handle_message_with_attachment(message, phone, **params)
attachment = message.attachments.first
if attachment.file.byte_size > max_size(attachment)
message.update!(status: :failed, external_error: 'File too large')
return
end
base64_data = attachment_to_base64(attachment)
buffer = "data:#{attachment.file.content_type};base64,#{base64_data}"
case attachment.file_type
when 'image'
send_image_message(phone, message, buffer, **params)
when 'audio'
send_audio_message(phone, message, buffer, **params)
when 'file'
send_document_message(phone, message, attachment, buffer, **params)
when 'video'
send_video_message(phone, message, buffer, **params)
end
end
def max_size(attachment)
case attachment.file_type
when 'image'
5.megabytes
when 'audio', 'video'
16.megabytes
else
100.megabytes
end
end
def send_image_message(phone, message, buffer, **params)
response = HTTParty.post(
"#{api_instance_path_with_token}/send-image",
headers: api_headers,
body: {
phone: phone,
image: buffer,
caption: message.outgoing_content,
**params
}.compact.to_json
)
raise ProviderUnavailableError unless process_response(response)
response.parsed_response&.dig('messageId')
end
def send_audio_message(phone, _message, buffer, **params)
response = HTTParty.post(
"#{api_instance_path_with_token}/send-audio",
headers: api_headers,
body: {
phone: phone,
audio: buffer,
waveform: true,
**params
}.compact.to_json
)
raise ProviderUnavailableError unless process_response(response)
response.parsed_response&.dig('messageId')
end
def send_document_message(phone, message, attachment, buffer, **params)
file_extension = File.extname(attachment.file.filename.to_s).delete('.')
if file_extension.blank?
Rails.logger.warn "Missing file extension for attachment: #{attachment.id}"
file_extension = 'bin'
end
response = HTTParty.post(
"#{api_instance_path_with_token}/send-document/#{file_extension}",
headers: api_headers,
body: {
phone: phone,
document: buffer,
fileName: attachment.file.filename.to_s,
caption: message.outgoing_content,
**params
}.compact.to_json
)
raise ProviderUnavailableError unless process_response(response)
response.parsed_response&.dig('messageId')
end
def send_video_message(phone, message, buffer, **params)
response = HTTParty.post(
"#{api_instance_path_with_token}/send-video",
headers: api_headers,
body: {
phone: phone,
video: buffer,
caption: message.outgoing_content,
**params
}.compact.to_json
)
raise ProviderUnavailableError unless process_response(response)
response.parsed_response&.dig('messageId')
end
def send_reaction_message(phone, message, **params)
response = HTTParty.post(
"#{api_instance_path_with_token}/send-reaction",
headers: api_headers,
body: {
phone: phone,
reaction: message.outgoing_content,
messageId: message.in_reply_to_external_id,
**params
}.compact.to_json
)
raise ProviderUnavailableError unless process_response(response)
response.parsed_response&.dig('messageId')
end
end

View File

@ -11,7 +11,7 @@ module Whatsapp::Providers
user_token = whatsapp_channel.wuzapi_user_token user_token = whatsapp_channel.wuzapi_user_token
# Normalize phone number: remove +, space, -, (, ) # Normalize phone number: remove +, space, -, (, )
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
if message.attachments.present? if message.attachments.present?
send_attachment_message(user_token, normalized_phone, message) send_attachment_message(user_token, normalized_phone, message)
else else
@ -32,10 +32,33 @@ module Whatsapp::Providers
end end
end end
def send_template(phone_number, template_info) def send_reaction_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
# Assuming message content is the emoji
reaction_emoji = message.content
# Assuming in_reply_to contains the ID of the message to react to
message_id = message.content_attributes['in_reply_to']
if message_id.present?
# Wuzapi client needs to implement send_reaction
# This assumes the client wrapper has this method. If not, we might need to add it or use raw request.
# Based on typical Wuzapi forks, it might be /send-reaction-message
# We'll assume the client wrapper will have a send_reaction method.
# If not visible in the existing codebase, we might need to add it to the client class too.
# checking...
client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji)
else
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
end
end
def send_template(_phone_number, _template_info)
# Placeholder for template support if Wuzapi supports it. # Placeholder for template support if Wuzapi supports it.
# For now, just logging or no-op as per initial text-focused plan. # For now, just logging or no-op as per initial text-focused plan.
Rails.logger.warn "Wuzapi: Templates not yet implemented or supported." Rails.logger.warn 'Wuzapi: Templates not yet implemented or supported.'
end end
def sync_templates def sync_templates

View File

@ -0,0 +1,24 @@
module Whatsapp::ZapiHandlers::ConnectedCallback
include Whatsapp::ZapiHandlers::Helpers
private
def process_connected_callback
expected_phone_number = inbox.channel.phone_number.delete('+')
received_phone_number = processed_params[:phone]
if normalize_phone_number(expected_phone_number) != normalize_phone_number(received_phone_number)
inbox.channel.update_provider_connection!(connection: 'close',
error: I18n.t('errors.inboxes.channel.provider_connection.wrong_phone_number'))
inbox.channel.disconnect_channel_provider
return
end
inbox.channel.update_provider_connection!(connection: 'open')
end
def normalize_phone_number(phone_number)
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer.new.normalize(phone_number)
end
end

View File

@ -0,0 +1,17 @@
module Whatsapp::ZapiHandlers::DeliveryCallback
include Whatsapp::ZapiHandlers::Helpers
private
def process_delivery_callback
message = inbox.messages.find_by(source_id: processed_params[:messageId])
return unless message
external_created_at = processed_params[:momment] / 1000
if processed_params[:error].present?
message.update!(status: :failed, external_error: processed_params[:error], external_created_at: external_created_at)
else
message.update!(status: :delivered, external_created_at: external_created_at)
end
end
end

View File

@ -0,0 +1,9 @@
module Whatsapp::ZapiHandlers::DisconnectedCallback
include Whatsapp::ZapiHandlers::Helpers
private
def process_disconnected_callback
inbox.channel.update_provider_connection!(connection: 'close')
end
end

View File

@ -0,0 +1,45 @@
module Whatsapp::ZapiHandlers::Helpers
include Whatsapp::IncomingMessageServiceHelpers
private
def raw_message_id
@raw_message[:isEdit] ? @raw_message[:editMessageId] : @raw_message[:messageId]
end
def incoming_message?
!@raw_message[:fromMe]
end
def cache_message_source_id_in_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
Redis::Alfred.setex(key, true)
end
def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
Redis::Alfred.delete(key)
end
def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
Redis::Alfred.get(key)
end
def with_zapi_contact_lock(phone, timeout: 5.seconds)
raise ArgumentError, 'A block is required for with_zapi_contact_lock' unless block_given?
start_time = Time.now.to_i
key = "ZAPI::CONTACT_LOCK::#{phone}"
while (Time.now.to_i - start_time) < timeout
break if Redis::Alfred.set(key, 1, nx: true, ex: timeout)
sleep(0.1)
end
yield
ensure
Redis::Alfred.delete(key)
end
end

View File

@ -0,0 +1,40 @@
module Whatsapp::ZapiHandlers::MessageStatusCallback
include Whatsapp::ZapiHandlers::Helpers
private
def process_message_status_callback
status = map_zapi_status_to_chatwoot(processed_params[:status])
return unless status
processed_params[:ids].each do |message_id|
message = inbox.messages.find_by(source_id: message_id)
next unless message
message.update!(status: status) if status_transition_allowed?(message, status.to_s)
end
end
def map_zapi_status_to_chatwoot(zapi_status)
case zapi_status.upcase
when 'SENT'
:sent
when 'DELIVERED', 'RECEIVED'
:delivered
when 'READ', 'READ_BY_ME', 'PLAYED'
:read
when 'FAILED'
:failed
else
Rails.logger.warn "Unknown ZAPI status: #{zapi_status}"
nil
end
end
def status_transition_allowed?(message, new_status)
return false if message.status == 'read'
return false if message.status == 'delivered' && new_status == 'sent'
true
end
end

View File

@ -0,0 +1,281 @@
module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/ModuleLength
include Whatsapp::ZapiHandlers::Helpers
private
def process_received_callback
@raw_message = processed_params
@message = nil
@contact_inbox = nil
@contact = nil
return unless should_process_message?
return if find_message_by_source_id(raw_message_id) || message_under_process?
cache_message_source_id_in_redis
return handle_edited_message if @raw_message[:isEdit]
with_zapi_contact_lock(@raw_message[:phone]) do
set_contact
unless @contact
Rails.logger.warn "Contact not found for message: #{raw_message_id}"
return
end
set_conversation
handle_create_message
end
ensure
clear_message_source_id_from_redis
end
def should_process_message?
!@raw_message[:isGroup] &&
!@raw_message[:isNewsletter] &&
!@raw_message[:broadcast] &&
!@raw_message[:isStatusReply] &&
!@raw_message.key?(:notification)
end
def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
return 'text' if @raw_message.key?(:text)
return 'reaction' if @raw_message.key?(:reaction)
return 'audio' if @raw_message.key?(:audio)
return 'image' if @raw_message.key?(:image)
return 'sticker' if @raw_message.key?(:sticker)
return 'video' if @raw_message.key?(:video)
return 'file' if @raw_message.key?(:document)
return 'contact' if @raw_message.key?(:contact)
'unsupported'
end
def message_content
case message_type
when 'text'
@raw_message.dig(:text, :message)
when 'image'
@raw_message.dig(:image, :caption)
when 'video'
@raw_message.dig(:video, :caption)
when 'file'
@raw_message.dig(:document, :fileName)
when 'reaction'
@raw_message.dig(:reaction, :value)
when 'contact'
@raw_message.dig(:contact, :displayName)
end
end
def contact_name
@raw_message[:senderName] || @raw_message[:chatName] || @raw_message[:phone]
end
def set_contact
push_name = contact_name
source_id = @raw_message[:chatLid].to_s.gsub(/[^\d]/, '')
identifier = @raw_message[:chatLid]
contact_attributes = { name: push_name, identifier: identifier }
unless @raw_message[:phone].ends_with?('@lid')
contact_attributes[:phone_number] = "+#{@raw_message[:phone]}"
update_existing_contact_inbox(@raw_message[:phone], source_id, identifier)
end
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: inbox,
contact_attributes: contact_attributes
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
@contact.update!(name: push_name) if @contact.name == @raw_message[:phone]
update_contact_phone_number
try_update_contact_avatar
end
def update_existing_contact_inbox(phone, source_id, identifier)
# NOTE: This is useful when we create a new contact manually, so we don't have information about contact LID;
# With this, when we receive a message from that contact, we can link it properly.
existing_contact = inbox.account.contacts.find_by(phone_number: "+#{phone}")
return unless existing_contact
existing_contact_inbox = existing_contact.contact_inboxes.find_by(inbox_id: inbox.id)
ActiveRecord::Base.transaction do
existing_contact.update!(identifier: identifier)
existing_contact_inbox&.update!(source_id: source_id)
end
end
def update_contact_phone_number
return if @contact.phone_number.present?
return if @raw_message[:phone].ends_with?('@lid')
@contact.update!(phone_number: "+#{@raw_message[:phone]}")
end
def try_update_contact_avatar
avatar_url = @raw_message[:senderPhoto] || @raw_message[:photo]
return unless avatar_url.present? && avatar_url.start_with?('http')
Avatar::AvatarFromUrlJob.perform_later(@contact, avatar_url)
end
def handle_create_message
if message_type == 'contact'
create_contact_message
else
create_message(attach_media: %w[image sticker file video audio].include?(message_type))
end
end
def create_contact_message
contact_data = @raw_message[:contact]
phones = contact_data[:phones] || []
phones = ['Phone number is not available'] if phones.blank?
phones.each do |phone|
build_message
attach_contact(phone, contact_data)
@message.save!
end
notify_channel_of_received_message
end
def create_message(attach_media: false)
build_message
handle_attach_media if attach_media
@message.save!
notify_channel_of_received_message
end
def build_message
@message = @conversation.messages.build(
content: message_content,
account_id: @inbox.account_id,
inbox_id: @inbox.id,
source_id: raw_message_id,
sender: incoming_message? ? @contact : @inbox.account.account_users.first.user,
sender_type: incoming_message? ? 'Contact' : 'User',
message_type: incoming_message? ? :incoming : :outgoing,
content_attributes: message_content_attributes
)
end
def notify_channel_of_received_message
inbox.channel.received_messages([@message], @conversation) if incoming_message?
end
def message_content_attributes
type = message_type
content_attributes = { external_created_at: @raw_message[:momment] / 1000 }
if type == 'reaction'
content_attributes[:in_reply_to_external_id] = @raw_message.dig(:reaction, :referencedMessage, :messageId)
content_attributes[:is_reaction] = true
elsif type == 'unsupported'
content_attributes[:is_unsupported] = true
end
content_attributes[:in_reply_to_external_id] = @raw_message[:referenceMessageId] if @raw_message[:referenceMessageId].present?
content_attributes
end
def attach_contact(phone, contact_data)
name_parts = contact_data[:displayName]&.split || []
@message.attachments.new(
account_id: @message.account_id,
file_type: :contact,
fallback_title: phone.to_s,
meta: {
firstName: name_parts.first,
lastName: name_parts.drop(1).join(' ')
}.compact_blank
)
end
def handle_attach_media
attachment_file = download_attachment_file
attachment = @message.attachments.build(
account_id: @message.account_id,
file_type: file_content_type.to_s,
file: { io: attachment_file, filename: filename, content_type: message_mimetype }
)
attachment.meta = { is_recorded_audio: true } if @raw_message.dig(:audio, :ptt)
rescue Down::Error => e
@message.update!(is_unsupported: true)
Rails.logger.error "Failed to download attachment for message #{raw_message_id}: #{e.message}"
end
def download_attachment_file
media_url = case message_type
when 'image'
@raw_message.dig(:image, :imageUrl)
when 'sticker'
@raw_message.dig(:sticker, :stickerUrl)
when 'audio'
@raw_message.dig(:audio, :audioUrl)
when 'video'
@raw_message.dig(:video, :videoUrl)
when 'file'
@raw_message.dig(:document, :documentUrl)
end
Down.download(media_url)
end
def filename
case message_type
when 'file'
@raw_message.dig(:document, :fileName)
else
ext = ".#{message_mimetype.split(';').first.split('/').last}" if message_mimetype.present?
"#{file_content_type}_#{raw_message_id}_#{Time.current.strftime('%Y%m%d')}#{ext}"
end
end
def file_content_type
return :image if %w[image sticker].include?(message_type)
return :video if message_type == 'video'
return :audio if message_type == 'audio'
:file
end
def message_mimetype
case message_type
when 'image'
@raw_message.dig(:image, :mimeType)
when 'sticker'
@raw_message.dig(:sticker, :mimeType)
when 'video'
@raw_message.dig(:video, :mimeType)
when 'audio'
@raw_message.dig(:audio, :mimeType)
when 'file'
@raw_message.dig(:document, :mimeType)
end
end
def handle_edited_message
@message = find_message_by_source_id(@raw_message[:messageId])
return unless @message
@message.update!(
content: message_content,
is_edited: true,
previous_content: @message.content
)
end
end

View File

@ -7,16 +7,13 @@ Rails.application.config.after_initialize do
if api_key.present? if api_key.present?
RubyLLM.configure do |config| RubyLLM.configure do |config|
config.openai_api_key = api_key config.openai_api_key = api_key
config.openai_organization_id = ENV['OPENAI_ORGANIZATION_ID'] if ENV['OPENAI_ORGANIZATION_ID'].present?
config.gemini_api_key = ENV['GEMINI_API_KEY'] if ENV['GEMINI_API_KEY'].present? config.gemini_api_key = ENV['GEMINI_API_KEY'] if ENV['GEMINI_API_KEY'].present?
end end
Rails.logger.info '[RubyLLM] Configured with OPENAI_API_KEY from environment' Rails.logger.info "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..."
elsif ENV['GEMINI_API_KEY'].present? puts "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." # Log to stdout for rails runner visibility
RubyLLM.configure do |config|
config.gemini_api_key = ENV['GEMINI_API_KEY']
end
Rails.logger.info '[RubyLLM] Configured with GEMINI_API_KEY from environment'
else else
Rails.logger.warn '[RubyLLM] No API Keys found in environment' Rails.logger.warn '[RubyLLM] No OPENAI_API_KEY found in environment'
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[RubyLLM] Failed to configure: #{e.message}" Rails.logger.error "[RubyLLM] Failed to configure: #{e.message}"

View File

@ -150,6 +150,9 @@ Rails.application.routes.draw do
resource :participants, only: [:show, :create, :update, :destroy] resource :participants, only: [:show, :create, :update, :destroy]
resource :direct_uploads, only: [:create] resource :direct_uploads, only: [:create]
resource :draft_messages, only: [:show, :update, :destroy] resource :draft_messages, only: [:show, :update, :destroy]
resource :crm_insight, only: [:show] do
post :refresh
end
end end
member do member do
post :mute post :mute

View File

@ -0,0 +1,13 @@
class CreateConversationCrmInsights < ActiveRecord::Migration[7.1]
def change
create_table :conversation_crm_insights do |t|
t.references :conversation, null: false, foreign_key: true, index: { unique: true }
t.references :contact, null: false, foreign_key: true
t.text :summary_text
t.jsonb :structured_data, default: {}
t.integer :contact_sessions_count, default: 0, null: false
t.datetime :last_contact_at
t.timestamps
end
end
end

View File

@ -0,0 +1,48 @@
class AddCrmInsightsHistoryFields < ActiveRecord::Migration[7.1]
def change
add_reference :conversation_crm_insights, :account, foreign_key: true
add_column :conversation_crm_insights, :generated_at, :datetime
add_column :conversation_crm_insights, :range_from_message_id, :bigint
add_column :conversation_crm_insights, :range_to_message_id, :bigint
add_column :conversation_crm_insights, :status, :string, default: 'success'
add_column :conversation_crm_insights, :error_message, :text
add_column :conversation_crm_insights, :schema_version, :string
add_column :conversation_crm_insights, :model, :string
add_column :conversation_crm_insights, :confidence, :float
remove_index :conversation_crm_insights, :conversation_id
add_index :conversation_crm_insights, :conversation_id
add_index :conversation_crm_insights, [:conversation_id, :generated_at]
add_index :conversation_crm_insights, :status
reversible do |dir|
dir.up do
execute <<~SQL.squish
UPDATE conversation_crm_insights
SET account_id = conversations.account_id
FROM conversations
WHERE conversation_crm_insights.conversation_id = conversations.id
AND conversation_crm_insights.account_id IS NULL
SQL
execute <<~SQL.squish
UPDATE conversation_crm_insights
SET generated_at = COALESCE(updated_at, created_at)
WHERE generated_at IS NULL
SQL
execute <<~SQL.squish
UPDATE conversation_crm_insights
SET range_to_message_id = summary.max_id
FROM (
SELECT conversation_id, MAX(id) AS max_id
FROM messages
GROUP BY conversation_id
) AS summary
WHERE conversation_crm_insights.conversation_id = summary.conversation_id
AND conversation_crm_insights.range_to_message_id IS NULL
SQL
end
end
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_01_010000) do ActiveRecord::Schema[7.1].define(version: 2026_01_04_150000) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@ -667,6 +667,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_01_010000) do
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id" t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
end end
create_table "conversation_crm_insights", force: :cascade do |t|
t.bigint "conversation_id", null: false
t.bigint "contact_id", null: false
t.text "summary_text"
t.jsonb "structured_data", default: {}
t.integer "contact_sessions_count", default: 0, null: false
t.datetime "last_contact_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "account_id"
t.datetime "generated_at"
t.bigint "range_from_message_id"
t.bigint "range_to_message_id"
t.string "status", default: "success"
t.text "error_message"
t.string "schema_version"
t.string "model"
t.float "confidence"
t.index ["account_id"], name: "index_conversation_crm_insights_on_account_id"
t.index ["contact_id"], name: "index_conversation_crm_insights_on_contact_id"
t.index ["conversation_id", "generated_at"], name: "idx_on_conversation_id_generated_at_44d5836366"
t.index ["conversation_id"], name: "index_conversation_crm_insights_on_conversation_id"
t.index ["status"], name: "index_conversation_crm_insights_on_status"
end
create_table "conversation_participants", force: :cascade do |t| create_table "conversation_participants", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false
@ -1395,6 +1420,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_01_010000) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "captain_tool_configs", "accounts" add_foreign_key "captain_tool_configs", "accounts"
add_foreign_key "captain_tool_configs", "inboxes" add_foreign_key "captain_tool_configs", "inboxes"
add_foreign_key "conversation_crm_insights", "accounts"
add_foreign_key "conversation_crm_insights", "contacts"
add_foreign_key "conversation_crm_insights", "conversations"
add_foreign_key "inboxes", "portals" add_foreign_key "inboxes", "portals"
add_foreign_key "jasmine_collections", "accounts" add_foreign_key "jasmine_collections", "accounts"
add_foreign_key "jasmine_collections", "inboxes", column: "owner_inbox_id" add_foreign_key "jasmine_collections", "inboxes", column: "owner_inbox_id"

View File

@ -40,10 +40,11 @@ services:
- VITE_DEV_SERVER_HOST=vite - VITE_DEV_SERVER_HOST=vite
- NODE_ENV=development - NODE_ENV=development
- RAILS_ENV=development - RAILS_ENV=development
- POSTGRES_PORT=5432 # Force internal port
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
entrypoint: docker/entrypoints/rails.sh entrypoint: docker/entrypoints/rails.sh
command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"] command: [ "bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0" ]
sidekiq: sidekiq:
<<: *base <<: *base
@ -60,7 +61,8 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- RAILS_ENV=development - RAILS_ENV=development
command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"] - POSTGRES_PORT=5432 # Force internal port
command: [ "bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml" ]
vite: vite:
<<: *base <<: *base
@ -79,9 +81,12 @@ services:
environment: environment:
- VITE_DEV_SERVER_HOST=0.0.0.0 - VITE_DEV_SERVER_HOST=0.0.0.0
- NODE_ENV=development - NODE_ENV=development
- NODE_ENV=development
- RAILS_ENV=development - RAILS_ENV=development
- DATABASE_URL= # Prevent override by .env
- POSTGRES_PORT=5432 # Force internal port
entrypoint: docker/entrypoints/vite.sh entrypoint: docker/entrypoints/vite.sh
command: bin/vite dev command: sh -c "gem install bundler:2.5.11 && bin/vite dev"
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg16
@ -99,7 +104,7 @@ services:
redis: redis:
image: redis:alpine image: redis:alpine
restart: always restart: always
command: ["sh", "-c", "redis-server --requirepass \"$REDIS_PASSWORD\""] command: [ "sh", "-c", "redis-server --requirepass \"$REDIS_PASSWORD\"" ]
env_file: .env env_file: .env
volumes: volumes:
- redis:/data/redis - redis:/data/redis

View File

@ -16,7 +16,7 @@ module Captain::ChatResponseHelper
JSON.parse(content) JSON.parse(content)
rescue JSON::ParserError => e rescue JSON::ParserError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}" Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}"
{ 'content' => content } { 'response' => content, 'reasoning' => 'parse_error' }
end end
def persist_thinking_message(tool_call) def persist_thinking_message(tool_call)

View File

@ -31,6 +31,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response def generate_and_process_response
Rails.logger.info 'ResponseBuilderJob: Generating response...' Rails.logger.info 'ResponseBuilderJob: Generating response...'
extract_contact_identity
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response( @response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages message_history: collect_previous_messages
) )
@ -39,6 +40,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end end
def generate_response_with_v2 def generate_response_with_v2
extract_contact_identity
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response( @response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages message_history: collect_previous_messages
) )
@ -71,6 +73,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end end
end end
def extract_contact_identity
last_message = @conversation.messages
.where(message_type: :incoming, private: false)
.order(created_at: :desc)
.first
return if last_message.blank?
Captain::Llm::ContactIdentityService.new(
contact: @conversation.contact,
message_content: last_message.content
).extract_and_update
end
def determine_role(message) def determine_role(message)
message.message_type == 'incoming' ? 'user' : 'assistant' message.message_type == 'incoming' ? 'user' : 'assistant'
end end
@ -105,8 +120,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end end
def create_messages def create_messages
validate_message_content!(@response['response']) response_text = inject_preferred_name(@response['response'])
create_outgoing_message(@response['response'], agent_name: @response['agent_name']) validate_message_content!(response_text)
create_outgoing_message(response_text, agent_name: @response['agent_name'])
end end
def validate_message_content!(content) def validate_message_content!(content)
@ -127,6 +143,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
) )
end end
def inject_preferred_name(content)
return content if content.blank?
attributes = @conversation.contact&.additional_attributes || {}
preferred_name = attributes['preferred_name'].to_s.strip
confidence = attributes['name_confidence'].to_f
return content if preferred_name.blank? || confidence < 0.8
return content if content.downcase.include?(preferred_name.downcase)
"#{preferred_name}, #{content}"
end
def handle_error(error) def handle_error(error)
log_error(error) log_error(error)
process_action('handoff') process_action('handoff')

View File

@ -66,12 +66,20 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end end
def create_response(faq, document) def create_response(faq, document)
document.responses.create!( response = document.responses.create!(
question: faq['question'], question: faq['question'],
answer: faq['answer'], answer: faq['answer'],
assistant: document.assistant, assistant: document.assistant,
documentable: document documentable: document
) )
return if response.embedding.present?
embedding = Captain::Llm::EmbeddingService.new(account_id: document.account_id).get_embedding(
"#{response.question}: #{response.answer}"
)
vector = embedding.is_a?(Array) && embedding.first.is_a?(Array) ? embedding.first : embedding
response.update_columns(embedding: vector)
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
Rails.logger.error I18n.t('captain.documents.response_creation_error', error: e.message) Rails.logger.error I18n.t('captain.documents.response_creation_error', error: e.message)
end end

View File

@ -5,6 +5,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
attr_reader :assistant, :conversation, :messages attr_reader :assistant, :conversation, :messages
def initialize(assistant:, conversation: nil) def initialize(assistant:, conversation: nil)
super()
Rails.logger.info "AssistantChatService: Initialized for Assistant #{assistant.id} / Conv #{conversation&.id}" Rails.logger.info "AssistantChatService: Initialized for Assistant #{assistant.id} / Conv #{conversation&.id}"
@assistant = assistant @assistant = assistant
@ -14,10 +15,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
@messages = [system_message] @messages = [system_message]
@response = '' @response = ''
# Override model if assistant has specific configuration # Prefer assistant model when set; otherwise keep configured default.
return unless @assistant&.respond_to?(:llm_model) && @assistant.llm_model.present? @model = @assistant.llm_model.presence || @model
@model = @assistant.llm_model
end end
def generate_response(additional_message: nil, message_history: [], role: 'user') def generate_response(additional_message: nil, message_history: [], role: 'user')
@ -75,6 +74,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
end end
end end
context_pack = context_pack_message
@messages << context_pack if context_pack.present?
@messages += message_history @messages += message_history
# Inject Tool Output into System Context if available (for playground or post-tool) # Inject Tool Output into System Context if available (for playground or post-tool)
@ -112,6 +113,121 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
} }
end end
def context_pack_message
return nil if @conversation.blank?
insight = @conversation.latest_crm_insight
insight_data = insight&.structured_data || {}
contact = @conversation.contact
contact_profile = contact&.additional_attributes || {}
preferred_name = contact_profile['preferred_name']
name_confidence = contact_profile['name_confidence']
name_source = contact_profile['name_source']
summary_text = insight&.summary_text.to_s.strip
summary_text = summary_text[0, 400] if summary_text.length > 400
content = build_context_pack(
preferred_name: preferred_name,
name_confidence: name_confidence,
name_source: name_source,
contact_profile: contact_profile,
insight: insight,
insight_data: insight_data,
summary_text: summary_text
)
Rails.logger.info("[Captain] Context pack size=#{content.length} conv_id=#{@conversation.id} insight_id=#{insight&.id || 'none'}")
{
role: 'system',
content: content
}
end
def build_context_pack(preferred_name:, name_confidence:, name_source:, contact_profile:, insight:, insight_data:, summary_text:)
header = "[CONTEXT PACK]\n"
guardrails = <<~GUARDRAILS
GUARDRAILS:
- Use o nome apenas se name_confidence >= 0.8.
- Se nao houver nome confiavel, pergunte uma vez e siga sem nome.
- Nao invente nome, preferencias ou dados que nao estejam no perfil/insights.
GUARDRAILS
contact_block = <<~CONTACT
CONTACT_PROFILE:
preferred_name: #{preferred_name.presence || 'desconhecido'}
name_confidence: #{name_confidence.presence || '0'}
name_source: #{name_source.presence || 'unknown'}
preferences: #{format_list(contact_profile['preferences'])}
frictions: #{format_list(contact_profile['frictions'])}
contact_pattern: #{format_hash(contact_profile['contact_pattern'])}
CONTACT
insights_block = <<~INSIGHTS
CONVERSATION_INSIGHTS (latest success):
#{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'}
summary_text: #{summary_text.presence || 'sem resumo valido'}
intent: #{format_value(insight_data['intent'])}
urgency: #{format_value(insight_data['urgency'])}
nba: #{format_hash(insight_data['nba'])}
suggested_labels: #{format_list(insight_data['suggested_labels'])}
INSIGHTS
max_length = 1500
parts = [header, contact_block, insights_block, guardrails]
combined = parts.join("\n").strip
return combined if combined.length <= max_length
trimmed_summary = summary_text[0, 200]
insights_block = <<~INSIGHTS
CONVERSATION_INSIGHTS (latest success):
#{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'}
summary_text: #{trimmed_summary.presence || 'sem resumo valido'}
intent: #{format_value(insight_data['intent'])}
urgency: #{format_value(insight_data['urgency'])}
nba: #{format_hash(insight_data['nba'])}
suggested_labels: #{format_list(insight_data['suggested_labels'])}
INSIGHTS
combined = [header, contact_block, insights_block, guardrails].join("\n").strip
return combined if combined.length <= max_length
insights_block = <<~INSIGHTS
CONVERSATION_INSIGHTS (latest success):
#{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'}
intent: #{format_value(insight_data['intent'])}
urgency: #{format_value(insight_data['urgency'])}
nba: #{format_hash(insight_data['nba'])}
suggested_labels: #{format_list(insight_data['suggested_labels'])}
INSIGHTS
combined = [header, contact_block, insights_block, guardrails].join("\n").strip
return combined if combined.length <= max_length
combined = [header, contact_block, guardrails].join("\n").strip
combined[0, max_length]
end
def format_list(value)
return 'nenhum' if value.blank?
return value.join(', ') if value.is_a?(Array)
value.to_s
end
def format_hash(value)
return 'nenhum' if value.blank?
return value.to_json if value.is_a?(Hash)
value.to_s
end
def format_value(value)
value.present? ? value.to_s : 'desconhecido'
end
def persist_message(message, message_type = 'assistant') def persist_message(message, message_type = 'assistant')
# No need to implement # No need to implement
end end

View File

@ -0,0 +1,64 @@
class Captain::Llm::ContactIdentityService
NAME_PATTERNS = [
/(?:meu nome e|meu nome é|me chama de|pode me chamar de|aqui e|aqui é|sou o|sou a)\s+([A-Za-zÀ-ÿ'\- ]{2,40})/i,
/(?:pode me chamar de|me chama de)\s+([A-Za-zÀ-ÿ'\- ]{2,40})/i,
/(?:me chamo|eu sou)\s+([A-Za-zÀ-ÿ'\- ]{2,40})/i
].freeze
CORRECTION_PATTERNS = [
/(?:me chama de|pode me chamar de|me chamo)\s+([A-Za-zÀ-ÿ'\- ]{2,40})/i
].freeze
def initialize(contact:, message_content:)
@contact = contact
@message_content = message_content.to_s.strip
end
def extract_and_update
return if @contact.blank? || @message_content.blank?
name = extract_name
return if name.blank?
attributes = @contact.additional_attributes || {}
existing_confidence = attributes['name_confidence'].to_f
is_correction = correction_message?
return if existing_confidence >= 0.8 && !is_correction
attributes['preferred_name'] = normalize_name(name)
attributes['name_confidence'] = 0.95
attributes['name_source'] = 'user_claimed'
attributes['last_confirmed_at'] = Time.current.iso8601
@contact.update!(additional_attributes: attributes)
end
private
def extract_name
match = nil
NAME_PATTERNS.each do |pattern|
match = @message_content.match(pattern)
break if match
end
return nil unless match
candidate = match[1].to_s.strip
candidate = candidate.split(/\b(e|eh|é)\b/i).first.to_s.strip
candidate = candidate.gsub(/[^\p{L}\s'\-]/, '').strip
return nil if candidate.length < 2
return nil if candidate.split.size > 3
candidate
end
def correction_message?
CORRECTION_PATTERNS.any? { |pattern| @message_content.match?(pattern) }
end
def normalize_name(name)
parts = name.split
parts.shift if parts.first&.downcase.in?(%w[a o])
parts.map { |part| part.capitalize }.join(' ')
end
end

View File

@ -2,7 +2,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation include Integrations::LlmInstrumentation
# Default pages per chunk - easily configurable # Default pages per chunk - easily configurable
DEFAULT_PAGES_PER_CHUNK = 10 DEFAULT_PAGES_PER_CHUNK = 5
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
attr_reader :total_pages_processed, :iterations_completed attr_reader :total_pages_processed, :iterations_completed
@ -107,7 +107,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
result = parse_chunk_response(response) result = parse_chunk_response(response)
{ faqs: result['faqs'] || [], has_content: result['has_content'] != false } { faqs: result['faqs'] || [], has_content: result['has_content'] != false }
rescue OpenAI::Error => e rescue OpenAI::Error, Faraday::Error, Timeout::Error, Net::ReadTimeout => e
Rails.logger.error I18n.t('captain.documents.page_processing_error', start: start_page, end: end_page, error: e.message) Rails.logger.error I18n.t('captain.documents.page_processing_error', start: start_page, end: end_page, error: e.message)
{ faqs: [], has_content: false } { faqs: [], has_content: false }
end end

View File

@ -7,57 +7,30 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
end end
def process def process
return if document.openai_file_id.present? return if document.content.present?
file_id = upload_pdf_to_openai extract_text_from_pdf
raise CustomExceptions::PdfUploadError, I18n.t('captain.documents.pdf_upload_failed') if file_id.blank? rescue StandardError => e
Rails.logger.error "PDF Processing Error: #{e.message}"
document.store_openai_file_id(file_id) raise e
end end
private private
attr_reader :document attr_reader :document
def upload_pdf_to_openai def extract_text_from_pdf
with_tempfile do |temp_file| content = ''
instrument_file_upload do document.pdf_file.open do |file|
response = @client.files.upload( reader = PDF::Reader.new(file)
parameters: { content = reader.pages.map(&:text).join("\n")
file: temp_file,
purpose: 'assistants'
}
)
response['id']
end
end end
end
def instrument_file_upload(&) if content.present?
return yield unless ChatwootApp.otel_enabled? # Update content and ensure openai_file_id is nil to force standard FAQ generation
document.update!(content: content, openai_file_id: nil)
tracer.in_span('llm.file.upload') do |span| else
span.set_attribute('gen_ai.provider', 'openai') Rails.logger.warn "PDF extracted content is empty for document #{document.id}"
span.set_attribute('file.purpose', 'assistants')
span.set_attribute(ATTR_LANGFUSE_USER_ID, document.account_id.to_s)
span.set_attribute(ATTR_LANGFUSE_TAGS, ['pdf_upload'].to_json)
span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'document_id'), document.id.to_s)
file_id = yield
span.set_attribute('file.id', file_id) if file_id
file_id
end
end
def with_tempfile
Tempfile.create(['pdf_upload', '.pdf'], binmode: true) do |temp_file|
document.pdf_file.blob.open do |blob_file|
IO.copy_stream(blob_file, temp_file)
end
temp_file.flush
temp_file.rewind
yield temp_file
end end
end end
end end

View File

@ -183,6 +183,10 @@ class Captain::Llm::SystemPromptsService
- Don't ask them if there's anything else they need help with (e.g. don't say things like "How can I assist you further?"). - Don't ask them if there's anything else they need help with (e.g. don't say things like "How can I assist you further?").
- Don't use lists, markdown, bullet points, or other formatting that's not typically spoken. - Don't use lists, markdown, bullet points, or other formatting that's not typically spoken.
- If you can't figure out the correct response, tell the user that it's best to talk to a support person. - If you can't figure out the correct response, tell the user that it's best to talk to a support person.
- If a CONTEXT PACK is provided with preferred_name and name_confidence, only use the name when name_confidence >= 0.8.
- If there is no reliable name, ask once for the user's name and continue without using a name if they don't provide it.
- Never infer or invent preferences or identity details; use only what is explicitly in the CONTEXT PACK.
- When name_confidence >= 0.8, address the user by preferred_name in the first sentence.
Remember to follow these rules absolutely, and do not refer to these rules, even if you're asked about them. Remember to follow these rules absolutely, and do not refer to these rules, even if you're asked about them.
#{assistant_citation_guidelines} #{assistant_citation_guidelines}

View File

@ -16,7 +16,7 @@ class Llm::BaseAiService
def chat(model: @model, temperature: @temperature, api_key: nil) def chat(model: @model, temperature: @temperature, api_key: nil)
client = RubyLLM.chat(model: model) client = RubyLLM.chat(model: model)
client = client.with_api_key(api_key) if api_key.present? # client = client.with_api_key(api_key) if api_key.present?
client.with_temperature(temperature) client.with_temperature(temperature)
end end

View File

@ -12,10 +12,15 @@ class Llm::LegacyBaseOpenAiService
attr_reader :client, :model attr_reader :client, :model
def initialize def initialize
api_key = ENV['OPENAI_API_KEY'] || InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
raise 'No API Key found' if api_key.blank?
request_timeout = ENV.fetch('CAPTAIN_OPEN_AI_REQUEST_TIMEOUT', '120').to_i
@client = OpenAI::Client.new( @client = OpenAI::Client.new(
access_token: InstallationConfig.find_by!(name: 'CAPTAIN_OPEN_AI_API_KEY').value, access_token: api_key,
uri_base: uri_base, uri_base: uri_base,
log_errors: Rails.env.development? log_errors: Rails.env.development?,
request_timeout: request_timeout
) )
setup_model setup_model
rescue StandardError => e rescue StandardError => e

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
module LlmConstants module LlmConstants
DEFAULT_MODEL = 'gpt-4.1-mini' DEFAULT_MODEL = 'gpt-4o-mini'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small' DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini' PDF_PROCESSING_MODEL = 'gpt-4o-mini'
OPENAI_API_ENDPOINT = 'https://api.openai.com' OPENAI_API_ENDPOINT = 'https://api.openai.com'

View File

@ -1,7 +1,7 @@
# NOTE: only doing this in development as some production environments (Heroku) # NOTE: only doing this in development as some production environments (Heroku)
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper # NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production. # NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development? if Rails.env.development? && ENV['SKIP_ANNOTATE'].blank?
require 'annotate_rb' require 'annotate_rb'
AnnotateRb::Core.load_rake_tasks AnnotateRb::Core.load_rake_tasks
@ -44,7 +44,7 @@ if Rails.env.development?
'ignore_unknown_models' => 'false', 'ignore_unknown_models' => 'false',
'hide_limit_column_types' => 'integer,bigint,boolean', 'hide_limit_column_types' => 'integer,bigint,boolean',
'hide_default_column_types' => 'json,jsonb,hstore', 'hide_default_column_types' => 'json,jsonb,hstore',
'skip_on_db_migrate' => 'false', 'skip_on_db_migrate' => 'true',
'format_bare' => 'true', 'format_bare' => 'true',
'format_rdoc' => 'false', 'format_rdoc' => 'false',
'format_markdown' => 'false', 'format_markdown' => 'false',

View File

@ -40,6 +40,11 @@ module Wuzapi
request(:post, '/chat/send/file', payload, user_auth_headers(user_token)) request(:post, '/chat/send/file', payload, user_auth_headers(user_token))
end end
def send_reaction(user_token, phone_number, message_id, emoji)
payload = { 'Phone' => phone_number, 'Body' => emoji, 'Id' => message_id }
request(:post, '/chat/react', payload, user_auth_headers(user_token))
end
def session_status(user_token) def session_status(user_token)
request(:get, '/session/status', nil, user_auth_headers(user_token)) request(:get, '/session/status', nil, user_auth_headers(user_token))
end end

View File

@ -0,0 +1,24 @@
# Fix: Playground 500 Error (Undefined method `with_api_key`)
## 🚨 Problema
O Playground do Captain AI falhava com erro 500 ao enviar mensagem.
**Erro nos logs:** `undefined method 'with_api_key' for an instance of RubyLLM::Chat` em `AssistantChatService`.
## 🔍 Causa
A biblioteca `RubyLLM` (versão atual usada no projeto) não suporta o método `.with_api_key()` na instância do chat, ou a interface mudou.
O código em `enterprise/app/services/llm/base_ai_service.rb` tentava injetar a chave de API dessa forma, causando o crash.
## 🛠️ Solução Aplicada
1. **Edição do Código**: Comentei a linha problemática em `enterprise/app/services/llm/base_ai_service.rb`.
```ruby
# client = client.with_api_key(api_key) if api_key.present?
```
2. **Configuração Global**: O sistema já possui um inicializador (`config/initializers/ruby_llm.rb`) que configura a chave globalmente via variável de ambiente `OPENAI_API_KEY`. Portanto, a remoção da chamada explícita não deve afetar o funcionamento se a chave estiver no `.env`.
## ✅ Verificação
- Reiniciei os containers `rails` e `sidekiq`.
- O Playground deve voltar a responder usando a chave global.

View File

@ -0,0 +1,27 @@
# Solução: FAQ não respondendo (Embeddings Ausentes)
## 🚨 Problema
A IA não estava usando as informações do Knowledge Base (FAQs) para responder, mesmo com as perguntas cadastradas.
Causa: A coluna `embedding` nas tabelas de resposta estava `NULL`.
## 🔍 Diagnóstico
- As FAQs foram criadas enquanto o sistema `sidekiq` estava crashando (devido aos erros anteriores de API Key/Sintaxe).
- O job assíncrono `Captain::Llm::UpdateEmbeddingJob` que gera os vetores nunca rodou.
- Sem vetores, a busca semântica (`SearchDocumentationService`) não encontra nada.
## 🛠️ Solução
Rodei um script via Console para forçar a geração de embeddings para os itens pendentes:
```ruby
Captain::AssistantResponse.where(embedding: nil).find_each do |r|
Captain::Llm::UpdateEmbeddingJob.perform_now(r, "#{r.question}: #{r.answer}")
end
```
## ✅ Resultado
- Banco de dados verificado: `has_embedding` agora é `true`.
- A IA agora deve conseguir encontrar "Qual valor da suite".

View File

@ -0,0 +1,36 @@
# Solução: PDF não processado (Erro 401 / Chave API)
## 🚨 Problema
Upload de documentos PDF falhava silenciosamente ou ficava preso em `in_progress`.
Ao tentar processar manualmente, ocorria erro `401 Unauthorized`.
## 🔍 Diagnóstico
1. **Chave Válida:** Testes com `curl` confirmaram que a chave `sk-proj-...` no `.env` estava correta e tinha permissões.
2. **Serviço Legado:** O Chatwoot usa um serviço separado para PDFs: `Captain::Llm::PdfProcessingService`, que herda de `Llm::LegacyBaseOpenAiService`.
3. **Causa Raiz:** A classe `LegacyBaseOpenAiService` estava **ignorando a variável de ambiente** `OPENAI_API_KEY` e buscando uma chave antiga/inválida diretamente na tabela `installation_configs` do banco de dados (`CAPTAIN_OPEN_AI_API_KEY`).
## 🛠️ Solução
Patcheamos o arquivo `enterprise/app/services/llm/legacy_base_open_ai_service.rb` para priorizar a variável de ambiente:
```ruby
def initialize
# Antes: Apenas banco de dados
# Agora: Tenta ENV primeiro, fallback para banco
api_key = ENV['OPENAI_API_KEY'] || InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
@client = OpenAI::Client.new(
access_token: api_key,
# ...
)
end
```
## ✅ Resultado
- Re-executamos o `CrawlJob` manualmente.
- O PDF foi enviado com sucesso para a OpenAI.
- O status do documento mudou para `available`.
- Embeddings estão sendo gerados.

View File

@ -0,0 +1,108 @@
# Guia de Configuração e Preview Local (Chatwoot Develop)
**Objetivo:** Rodar o ambiente de desenvolvimento `chatwoot-develop` localmente via Docker para validação de funcionalidades (ex: Integrações WhatsApp).
## 🚀 Como Iniciar Rapidamente
1. **Vá para a pasta do projeto:**
```bash
cd /Users/user/Chatwoot/chatwoot-develop
```
2. **Suba os containers:**
```bash
docker-compose up
```
_Aguarde até ver logs indicando que `rails` (porta 3000) e `vite` (porta 3036) estão prontos._
3. **Acesse:**
Acesse [http://localhost:3000](http://localhost:3000).
- **Login:** `rodrigobm10@gmail.com`
- **Senha:** `Password123!`
---
## 🛠️ Solução de Problemas Comuns (Troubleshooting)
Se algo der errado, consulte os erros abaixo que enfrentamos e como resolvemos:
### 1. Erro: `ActiveRecord::NoDatabaseError`
**Sintoma:** Tela vermelha dizendo que o banco `chatwoot_dev` não existe.
**Causa:** O banco de dados Postgres foi iniciado, mas o banco específico da aplicação não foi criado.
**Solução:**
Abra um **novo terminal** na pasta do projeto e rode:
```bash
docker-compose exec rails bundle exec rails db:create db:migrate
```
_Se pedir para rodar seeds, adicione `db:seed` ao final._
### 2. Erro: `No account found` (Login travado)
**Sintoma:** Você faz login, mas cai numa tela branca dizendo "No account found".
**Causa:** O usuário existe no banco, mas não tem vínculo (`AccountUser`) com nenhuma conta.
**Solução:**
Rode este comando (SQL Direto) para forçar o vínculo e liberar o acesso:
```bash
docker-compose exec postgres psql -U postgres -d chatwoot_dev -c "INSERT INTO account_users (user_id, account_id, role, created_at, updated_at) VALUES (1, 1, 0, NOW(), NOW()) ON CONFLICT DO NOTHING;"
```
### 3. Erro no Vite: `Activating bundler (2.5.11) failed`
**Sintoma:** O container `vite` cai com erro dizendo que não achou o bundler 2.5.11.
**Causa:** A imagem Docker pode ter uma versão do Ruby/Bundler diferente do `Gemfile.lock`.
**Solução:**
Editamos o `docker-compose.yaml` para instalar a versão correta antes de rodar:
```yaml
# docker-compose.yaml
vite:
command: sh -c "gem install bundler:2.5.11 && bin/vite dev"
```
### 4. Erro de Conexão com Banco (Porta 5438 vs 5432)
**Sintoma:** O Rails não conecta no Postgres (`Connection refused`).
**Causa:** O arquivo `.env` local define `POSTGRES_PORT=5438` (porta externa), mas dentro da rede Docker o Rails deve falar na porta interna `5432`.
**Solução:**
Forçamos a porta interna no `docker-compose.yaml` (services `rails`, `sidekiq`, `migrate`):
```yaml
environment:
- POSTGRES_PORT=5432
- DATABASE_URL= # Deixar vazio para não pegar do .env
```
### 5. Erro no Build Frontend: `PromoBanner.vue not found`
**Sintoma:** O `vite` falha ao compilar dizendo que não acha esse arquivo.
**Causa:** Arquivo presente na branch `main` mas faltando na `develop`.
**Solução:**
Criar o arquivo manualmente em `app/javascript/dashboard/components-next/banner/PromoBanner.vue` (copiando do repo principal).
---
## 📝 Comandos Úteis
- **Reiniciar serviços (limpa alguns erros voláteis):**
```bash
docker-compose restart rails sidekiq
```
- **Limpar Cache (Redis):**
```bash
docker-compose exec redis redis-cli FLUSHALL
```
- **Resetar tudo (Cuidado: apaga dados):**
```bash
docker-compose down -v
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -184,5 +184,21 @@ export const icons = {
width: 14, width: 14,
height: 14, height: 14,
}, },
/* Custom Icons */
baileys: {
body: `
<circle cx="50" cy="50" r="47" fill="none" stroke="currentColor" stroke-width="3"/>
<path d="M28 26 L72 16 L42 54 L42 34 Z" fill="currentColor"/>
<path d="M72 74 L28 84 L58 46 L58 66 Z" fill="currentColor"/>
`,
width: 100,
height: 100,
},
zapi: {
body: `<path stroke="currentColor"
d="M 0.15050214,31.611667 -6.727909e-7,31.2227 0.89864166,27.585988 1.8729551,26.090955 2.8472685,24.595922 15.789266,11.660059 4.1063138,11.40095 4.1544241,11.012276 C 4.2020703,10.627325 4.3819706,9.9942713 5.5949694,5.9429145 5.9390689,4.7936802 6.5411296,3.3109868 6.9329149,2.6479859 L 7.645179,1.4426365 9.5522129,-2.6631488e-6 H 47.999999 L 47.954743,0.90347077 47.909486,1.8069344 46.293465,3.2388989 H 18.637918 l -0.162915,0.26143 -0.162916,0.26143 0.330216,1.0318136 h 23.740162 v 0.4134697 c 0,0.2274083 -0.196756,0.7438048 -0.437239,1.1475694 l -0.437239,0.7340801 -0.542525,0.1444794 c -0.298389,0.079423 -3.834608,0.147858 -7.858122,0.151981 l -7.315596,0.00783 -0.698903,0.4393434 0.08817,0.618481 0.08817,0.6184712 11.496318,0.2591188 v 1.2955455 l -2.331555,1.165981 h -5.153988 l -9.441753,9.256746 0.367355,0.589484 h 14.050945 l -0.218768,0.712554 c -0.120325,0.391915 -0.610257,1.93279 -1.088704,3.424199 l -0.869975,2.711645 -0.791895,1.169408 -0.791895,1.169408 -1.045057,0.584401 -1.045057,0.584391 -28.10287818,0.0078 z"/>`,
width: 48,
height: 32,
},
/** Ends */ /** Ends */
}; };