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
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)
head :ok
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)
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) {
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();

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(
() => uiSettings.value.is_copilot_panel_open
);
const isCrmInsightsOpen = computed(() => uiSettings.value.is_crm_insights_open);
const toggleConversationSidebarToggle = () => {
if (isCrmInsightsOpen.value) {
updateUISettings({
is_crm_insights_open: false,
});
return;
}
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
is_crm_insights_open: false,
});
};
const handleConversationSidebarToggle = () => {
if (isCrmInsightsOpen.value) {
updateUISettings({
is_crm_insights_open: false,
});
return;
}
updateUISettings({
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
is_crm_insights_open: false,
});
};
const handleCopilotSidebarToggle = () => {
if (isCrmInsightsOpen.value) {
updateUISettings({
is_crm_insights_open: false,
});
return;
}
updateUISettings({
is_contact_sidebar_open: false,
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 option = llmProviderOptions.find(opt => opt.value === state.llmProvider);
const option = llmProviderOptions.find(
opt => opt.value === state.llmProvider
);
return option ? option.label : 'Selecione um provedor';
});
@ -111,23 +113,44 @@ const llmProviderLabel = computed(() => {
const llmModelOptions = computed(() => {
if (state.llmProvider === 'openai') {
return [
{ value: 'gpt-4o', label: 'GPT-4o (Mais Inteligente)' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini (Rápido e Econômico)' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
{ value: 'gpt-3.5-turbo-0125', label: 'GPT-3.5 Turbo' },
{ value: 'gpt-5.2', label: 'GPT-5.2 (Mais Potente)' },
{ value: 'gpt-5.2-pro', label: 'GPT-5.2 Pro (Premium)' },
{ value: 'gpt-5.1', label: 'GPT-5.1' },
{ 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 [
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash (Rápido)' },
{ value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash (Experimental)' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro (Mais Potente)' },
{ value: 'gemini-3-flash', label: 'Gemini 3 Flash (Rapido)' },
{ 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 [];
});
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';
});

View File

@ -13,6 +13,8 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
chat: {
@ -28,6 +30,7 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
@ -90,6 +93,16 @@ const hasMultipleInboxes = computed(
);
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>
<template>
@ -151,6 +164,17 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:parent-width="width"
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" />
</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"
}
},
"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": {
"MARK_PENDING": "Mark as pending",
"SNOOZE_UNTIL": "Snooze",

View File

@ -103,6 +103,65 @@
"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": {
"MARK_PENDING": "Deixar pendente",
"SNOOZE_UNTIL": "Adiar",

View File

@ -238,7 +238,11 @@
"TWILIO_DESC": "Conectar através de credenciais Twilio",
"360_DIALOG": "360Dialog",
"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": {
"BASE_URL": {
@ -256,7 +260,22 @@
},
"SELECT_PROVIDER": {
"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": {
"LABEL": "Nome da Caixa de Entrada",
@ -295,37 +314,42 @@
"WEBHOOK_URL": "URL do Webhook",
"WEBHOOK_VERIFICATION_TOKEN": "Token de verificação Webhook"
},
"SUBMIT_BUTTON": "Criar canal do WhatsApp",
"EMBEDDED_SIGNUP": {
"TITLE": "Configuração rápida com Meta",
"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.",
"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"
"PROVIDER_URL": {
"LABEL": "URL do provedor",
"PLACEHOLDER": "Se o provedor não está rodando localmente, por favor, insira a URL do provedor",
"ERROR": "Por favor, insira uma URL válida"
},
"LEARN_MORE": {
"TEXT": "Para saber mais sobre a inscrição integrada, preços e limitações, visite {link}.",
"LINK_TEXT": "este link"
"MARK_AS_READ": {
"LABEL": "Enviar confirmações de leitura"
},
"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"
"INSTANCE_ID": {
"LABEL": "ID da instância",
"PLACEHOLDER": "Por favor, insira o ID da sua instância",
"ERROR": "Este campo é obrigatório"
},
"API": {
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
"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": {

View File

@ -10,6 +10,7 @@ import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBar
import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
import CrmInsightsSidebar from 'dashboard/components/widgets/conversation/CrmInsightsSidebar.vue';
export default {
components: {
@ -18,6 +19,7 @@ export default {
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
CrmInsightsSidebar,
},
beforeRouteLeave(to, from, next) {
// 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;
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: {
@ -214,6 +223,10 @@ export default {
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CrmInsightsSidebar
v-if="shouldShowCrmInsights"
:current-chat="currentChat"
/>
<CmdBarConversationSnooze />
</section>
</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 WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import Wuzapi from './Wuzapi.vue';
import ZapiWhatsapp from './ZapiWhatsapp.vue';
import BaileysWhatsapp from './BaileysWhatsapp.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const route = useRoute();
const router = useRouter();
@ -20,6 +23,8 @@ const PROVIDER_TYPES = {
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
WHATSAPP_MANUAL: 'whatsapp_manual',
THREE_SIXTY_DIALOG: '360dialog',
BAILEYS: 'baileys',
ZAPI: 'zapi',
WUZAPI: 'wuzapi',
};
@ -49,6 +54,18 @@ const availableProviders = computed(() => [
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
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,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI'),
@ -99,6 +116,29 @@ const handleManualLinkClick = () => {
@click="selectProvider(provider.key)"
/>
</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 v-else-if="showConfiguration">
@ -139,6 +179,10 @@ const handleManualLinkClick = () => {
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<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 -->
<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,7 +25,7 @@ class Channel::Whatsapp < ApplicationRecord
EDITABLE_ATTRS = [:phone_number, :provider, :wuzapi_user_token, :wuzapi_admin_token, { provider_config: {} }].freeze
# 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
@ -51,6 +51,10 @@ class Channel::Whatsapp < ApplicationRecord
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
when 'wuzapi'
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
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
end
@ -63,6 +67,7 @@ class Channel::Whatsapp < ApplicationRecord
end
delegate :send_message, to: :provider_service
delegate :send_reaction_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
delegate :media_url, to: :provider_service
@ -75,10 +80,85 @@ class Channel::Whatsapp < ApplicationRecord
prompt_reauthorization!
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
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
def move_tokens_to_encrypted_attributes
@ -89,11 +169,11 @@ class Channel::Whatsapp < ApplicationRecord
provider_config.delete('wuzapi_user_token')
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')
end
end
def validate_provider_config
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
@ -120,6 +200,8 @@ class Channel::Whatsapp < ApplicationRecord
rescue StandardError => e
Rails.logger.error "Wuzapi Webhook Setup Failed: #{e.message}"
end
elsif provider_service.respond_to?(:setup_channel_provider)
provider_service.setup_channel_provider
else
# 360Dialog / Cloud logic
business_account_id = provider_config['business_account_id']
@ -153,8 +235,8 @@ class Channel::Whatsapp < ApplicationRecord
rescue StandardError => e
Rails.logger.warn "Wuzapi Provisioning failed with URL #{base_url}: #{e.message}"
# Fallback: if url ends in /api, strip it and try again
if base_url.match?(/\/api\/?$/)
fallback_url = base_url.gsub(/\/api\/?$/, '')
if base_url.match?(%r{/api/?$})
fallback_url = base_url.gsub(%r{/api/?$}, '')
Rails.logger.info "Retrying Wuzapi Provisioning with fallback URL: #{fallback_url}"
begin
result = attempt_provision.call(fallback_url)

View File

@ -62,6 +62,7 @@ class Contact < ApplicationRecord
has_many :inboxes, through: :contact_inboxes
has_many :messages, as: :sender, dependent: :destroy_async
has_many :notes, dependent: :destroy_async
has_many :crm_insights, class_name: 'ConversationCrmInsight', dependent: :destroy_async
before_validation :prepare_contact_attributes
after_create_commit :dispatch_create_event, :ip_lookup
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 :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async
has_many :crm_insights, class_name: 'ConversationCrmInsight', dependent: :destroy
before_save :ensure_snooze_until_reset
before_create :determine_conversation_status
@ -211,6 +212,14 @@ class Conversation < ApplicationRecord
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
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
def execute_after_update_commit_callbacks
@ -227,6 +236,8 @@ class Conversation < ApplicationRecord
# rubocop:disable Rails/SkipsModelValidations
update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
CrmInsights::UpdateJob.perform_later(id, reason: 'resolved')
end
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
execute_message_template_hooks
update_contact_activity
schedule_crm_insights_update
end
def update_contact_activity
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact)
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
if human_response? && !private && conversation.waiting_since.present?
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

@ -32,10 +32,33 @@ module Whatsapp::Providers
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.
# 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
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?
RubyLLM.configure do |config|
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?
end
Rails.logger.info '[RubyLLM] Configured with OPENAI_API_KEY from environment'
elsif ENV['GEMINI_API_KEY'].present?
RubyLLM.configure do |config|
config.gemini_api_key = ENV['GEMINI_API_KEY']
end
Rails.logger.info '[RubyLLM] Configured with GEMINI_API_KEY from environment'
Rails.logger.info "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..."
puts "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." # Log to stdout for rails runner visibility
else
Rails.logger.warn '[RubyLLM] No API Keys found in environment'
Rails.logger.warn '[RubyLLM] No OPENAI_API_KEY found in environment'
end
rescue StandardError => e
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 :direct_uploads, only: [:create]
resource :draft_messages, only: [:show, :update, :destroy]
resource :crm_insight, only: [:show] do
post :refresh
end
end
member do
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.
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
enable_extension "pg_stat_statements"
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"
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|
t.bigint "account_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 "captain_tool_configs", "accounts"
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 "jasmine_collections", "accounts"
add_foreign_key "jasmine_collections", "inboxes", column: "owner_inbox_id"

View File

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

View File

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

View File

@ -31,6 +31,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response
Rails.logger.info 'ResponseBuilderJob: Generating response...'
extract_contact_identity
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
@ -39,6 +40,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def generate_response_with_v2
extract_contact_identity
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
@ -71,6 +73,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
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)
message.message_type == 'incoming' ? 'user' : 'assistant'
end
@ -105,8 +120,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def create_messages
validate_message_content!(@response['response'])
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
response_text = inject_preferred_name(@response['response'])
validate_message_content!(response_text)
create_outgoing_message(response_text, agent_name: @response['agent_name'])
end
def validate_message_content!(content)
@ -127,6 +143,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
)
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)
log_error(error)
process_action('handoff')

View File

@ -66,12 +66,20 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def create_response(faq, document)
document.responses.create!(
response = document.responses.create!(
question: faq['question'],
answer: faq['answer'],
assistant: document.assistant,
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
Rails.logger.error I18n.t('captain.documents.response_creation_error', error: e.message)
end

View File

@ -5,6 +5,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
attr_reader :assistant, :conversation, :messages
def initialize(assistant:, conversation: nil)
super()
Rails.logger.info "AssistantChatService: Initialized for Assistant #{assistant.id} / Conv #{conversation&.id}"
@assistant = assistant
@ -14,10 +15,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
@messages = [system_message]
@response = ''
# Override model if assistant has specific configuration
return unless @assistant&.respond_to?(:llm_model) && @assistant.llm_model.present?
@model = @assistant.llm_model
# Prefer assistant model when set; otherwise keep configured default.
@model = @assistant.llm_model.presence || @model
end
def generate_response(additional_message: nil, message_history: [], role: 'user')
@ -75,6 +74,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
end
end
context_pack = context_pack_message
@messages << context_pack if context_pack.present?
@messages += message_history
# Inject Tool Output into System Context if available (for playground or post-tool)
@ -112,6 +113,121 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
}
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')
# No need to implement
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
# 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
attr_reader :total_pages_processed, :iterations_completed
@ -107,7 +107,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
result = parse_chunk_response(response)
{ 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)
{ faqs: [], has_content: false }
end

View File

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

View File

@ -16,7 +16,7 @@ class Llm::BaseAiService
def chat(model: @model, temperature: @temperature, api_key: nil)
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)
end

View File

@ -12,10 +12,15 @@ class Llm::LegacyBaseOpenAiService
attr_reader :client, :model
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(
access_token: InstallationConfig.find_by!(name: 'CAPTAIN_OPEN_AI_API_KEY').value,
access_token: api_key,
uri_base: uri_base,
log_errors: Rails.env.development?
log_errors: Rails.env.development?,
request_timeout: request_timeout
)
setup_model
rescue StandardError => e

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_MODEL = 'gpt-4o-mini'
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'

View File

@ -1,7 +1,7 @@
# 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: 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'
AnnotateRb::Core.load_rake_tasks
@ -44,7 +44,7 @@ if Rails.env.development?
'ignore_unknown_models' => 'false',
'hide_limit_column_types' => 'integer,bigint,boolean',
'hide_default_column_types' => 'json,jsonb,hstore',
'skip_on_db_migrate' => 'false',
'skip_on_db_migrate' => 'true',
'format_bare' => 'true',
'format_rdoc' => 'false',
'format_markdown' => 'false',

View File

@ -40,6 +40,11 @@ module Wuzapi
request(:post, '/chat/send/file', payload, user_auth_headers(user_token))
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)
request(:get, '/session/status', nil, user_auth_headers(user_token))
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,
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 */
};