Files
clube67_newwhats.local/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts
T
VPS 4 Deploy Agent 3adbc0b59d
continuous-integration/webhook Deploy concluido com sucesso (VPS 4)
fix(secretaria): corrige 7 inconsistências do issue #14
SEC-01+SEC-11: /secretaria/ask agora envia reply via WhatsApp (sock.sendMessage)
  e persiste a mensagem no banco Prisma após brain.chat(), com notify Socket.IO.

SEC-02+SEC-03: MessageHandler emite hookBus 'ext:message.new' para todo msg
  recebido. Listener no ext-api auto-dispara a Secretária apenas se NÃO
  houver AIBot ativo na instância (exclusão mútua — chatbot tem prioridade).

SEC-04: ChatListItem exibe ícone BotOff (âmbar) quando chat.bot_paused = true,
  com tooltip "Bot pausado — aguardando atendente".

SEC-05: chatToLegacy() mapeia c.botPaused → bot_paused na bridge legada.
  NewWhatsChat recebe campo bot_paused opcional.

SEC-08: POST /secretaria/ask valida que existe instância CONNECTED antes
  de criar conversa. Retorna 503 se nenhuma instância disponível.

SEC-10: POST /api/chatbot/bots/:instanceId valida que credentialId pertence
  ao mesmo tenantId antes de criar o bot. Retorna 403 se não pertencer.

SEC-12: chatBotState.resumeBot() limpa botSummary (= null) ao retomar,
  evitando contexto defasado após escalação humana.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 05:29:19 +02:00

274 lines
11 KiB
TypeScript

/**
* useNewInboxBridge — adapta chatStore + instanceStore para o formato
* que os componentes de display legados já consomem.
* Nenhum componente visual precisa ser alterado.
*/
import { useMemo, useCallback, useEffect, useRef } from 'react'
import { useChatStore } from '../../store/chatStore'
import { useInstanceStore } from '../../store/instanceStore'
import { chatApi, messageApi, reconcileApi } from '../../services/chatApiService'
import { socketService } from '../../services/socketService'
import { formatPhone, getAvatarProxyUrl } from '../../utils/inboxUtils'
import type { NewWhatsChat } from '../../types/inboxTypes'
// ─── Mapeamentos legado ───────────────────────────────────────────────────────
function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]): NewWhatsChat {
const isGroup = c.jid.endsWith('@g.us')
const jidUser = c.jid.split('@')[0]
// Telefone formatado — sempre passa por formatPhone para normalizar E.164 → exibição
// c.contactPhone é o número cru do DB (ex: "5511999887766"); formatPhone aplica máscara
const formattedPhone = isGroup ? jidUser : formatPhone(c.contactPhone ?? jidUser, c.jid)
// 1. Nome de Exibição
// Regra estrita: grupos usam SEMPRE o subject (c.name) — nunca o
// pushName/contactName do último participante que enviou mensagem.
let displayName = isGroup
? (c.name || 'Grupo')
: (c.contactName || c.name || formattedPhone || jidUser)
// Limpeza final: se por algum motivo ainda vier o JID completo, removemos o sufixo
if (displayName.includes('@')) {
displayName = displayName.split('@')[0]
}
// 2. Avatar — só aponta pro proxy quando o banco (contactAvatarUrl) já
// confirmou que existe foto. Quando é null, Avatar.tsx mostra iniciais
// direto, sem disparar requisição — elimina os 60+ GETs/lista que o
// proxy respondia 204. A URL do proxy é estável (o backend lida com a
// expiração do CDN do WhatsApp internamente).
const avatar_url: string | null = c.contactAvatarUrl
? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact')
: null
const avatar_version: string | null = c.contactAvatarVersion ?? null
return {
id: c.id as any,
instance_name: c.instanceId,
remote_jid: c.jid,
phone: formattedPhone,
lead_name: c.contactName,
avatar_url,
avatar_version,
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
displayName,
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
last_message_text: c.lastMessageBody || null,
last_message_time: c.lastMessageAt || c.createdAt,
last_message_from_me: c.lastMessageFromMe ? 1 : 0,
last_message_status: c.lastMessageStatus?.toLowerCase() ?? null,
last_message_type: c.lastMessageType?.toLowerCase() ?? null,
last_message_sender: c.lastMessageSender ?? null,
unread_count: c.unreadCount,
is_pinned: c.isPinned,
is_archived: c.isArchived,
bot_paused: c.botPaused ?? null,
labels: [],
}
}
function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messages'][string]) {
return (msgs ?? []).map((m) => ({
id: m.id as any,
chat_id: m.chatId as any,
message_id: m.messageId,
text: m.body ?? '',
direction: (m.fromMe ? 'out' : 'in') as 'in' | 'out',
timestamp: new Date(m.timestamp).getTime(),
status: m.status.toLowerCase(),
type: m.type.toLowerCase(),
media_url: m.mediaUrl,
mime_type: m.mimeType,
reply_to_id: m.replyToId,
// Quoted message — mapeia replyTo do Prisma para o formato do MessageBubble
quoted_id: m.replyTo?.id ?? null,
quoted_message_id: m.replyTo?.messageId ?? null,
quoted_message_text: m.replyTo?.body ?? null,
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
participant: m.pushName ?? null,
senderJid: m.senderJid ?? null,
sender_jid: m.senderJid ?? null,
remote_jid: m.chatId,
from_me: m.fromMe ? 1 : 0,
rich_payload: (m as any).richPayload ?? null,
}))
}
// ─── Hook principal ───────────────────────────────────────────────────────────
export function useNewInboxBridge() {
const initialized = useRef(false)
const chatStore = useChatStore()
const instanceStore = useInstanceStore()
// ── Boot: socket + instâncias ─────────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
const token = localStorage.getItem('token')
if (!token) return
socketService.connect(token)
chatStore.initSocketListeners()
instanceStore.initSocketListeners()
instanceStore.loadInstances()
}, [])
// ── Carregar chats quando instância ativa muda ─────────────────────────────
useEffect(() => {
const { activeInstanceId } = instanceStore
if (!activeInstanceId) return
chatStore.loadChats(activeInstanceId)
socketService.joinInstance(activeInstanceId)
// Reconcilia nomes em background (cache 30min — leve em escala)
reconcileApi.reconcileNames(activeInstanceId).catch(() => {})
}, [instanceStore.activeInstanceId])
// ── Adaptação: instâncias → legado ────────────────────────────────────────
const instancesLegacy = useMemo(
() =>
instanceStore.instances.map((i) => ({
instance: i.id,
nome: i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: null,
profilePictureUrl: null,
})),
[instanceStore.instances]
)
const activeInstanceLegacy = useMemo(
() => instancesLegacy.find((i) => i.instance === instanceStore.activeInstanceId) ?? null,
[instancesLegacy, instanceStore.activeInstanceId]
)
// ── Adaptação: chats → legado ─────────────────────────────────────────────
const chatsLegacy = useMemo(() => chatStore.chats.map(chatToLegacy), [chatStore.chats])
const selectedChatLegacy = useMemo(() => {
if (!chatStore.activeChatId) return null
const c = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
return c ? chatToLegacy(c) : null
}, [chatStore.activeChatId, chatStore.chats])
const messagesLegacy = useMemo(() => {
if (!chatStore.activeChatId) return []
const raw = chatStore.messages[chatStore.activeChatId] ?? []
const mapped = messagesToLegacy(raw)
console.log('[bridge] messagesLegacy recomputed:', { activeChatId: chatStore.activeChatId, rawCount: raw.length, mappedCount: mapped.length })
return mapped
}, [chatStore.activeChatId, chatStore.messages])
// ── Handlers ──────────────────────────────────────────────────────────────
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
if (!chat) { useChatStore.getState().setActiveChat(null); return }
await chatStore.selectChat(String(chat.id))
}, [chatStore])
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
if (!chatStore.activeChatId || !instanceStore.activeInstanceId) return
const chat = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
if (!chat) return
await chatStore.sendMessage(instanceStore.activeInstanceId, chat.jid, text, replyToMessageId, mentions)
}, [chatStore, instanceStore.activeInstanceId])
const handleLoadMoreHistory = useCallback(async () => {
if (!chatStore.activeChatId) return { hasMore: false }
const oldest = chatStore.messages[chatStore.activeChatId]?.[0]
if (!oldest) return { hasMore: false }
return chatStore.loadMessages(chatStore.activeChatId, { before: oldest.timestamp })
}, [chatStore])
const handleSetActiveInstance = useCallback((legacyInstance: any) => {
instanceStore.setActiveInstance(legacyInstance?.instance ?? null)
}, [instanceStore])
const handleRefreshInstances = useCallback(async () => {
await instanceStore.loadInstances()
}, [instanceStore])
const handleConnectInstance = useCallback(async (instanceId: string) => {
await instanceStore.connectInstance(instanceId)
}, [instanceStore])
const handleArchiveChat = useCallback(async (chatId: string, archived: boolean) => {
await chatApi.archive(chatId, archived)
useChatStore.getState().patchChat(chatId, { isArchived: archived })
}, [])
const handlePinChat = useCallback(async (chatId: string, pinned: boolean) => {
await chatApi.pin(chatId, pinned)
useChatStore.getState().patchChat(chatId, { isPinned: pinned })
}, [])
const handleDeleteChat = useCallback(async (chatId: string) => {
await chatApi.delete(chatId)
useChatStore.getState().setChats(
useChatStore.getState().chats.filter((c) => c.id !== chatId)
)
if (useChatStore.getState().activeChatId === chatId) {
useChatStore.getState().setActiveChat(null)
}
}, [])
const handleMarkRead = useCallback(async (chatId: string) => {
await chatApi.markRead(chatId)
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
}, [])
const handleSearchMessages = useCallback(async (q: string) => {
if (!instanceStore.activeInstanceId || q.length < 2) return []
return messageApi.search(instanceStore.activeInstanceId, q)
}, [instanceStore.activeInstanceId])
// action é a string-key emitida por ChatListItem (ex: 'archive', 'pin', ...)
// onFavoriteToggle é injetado pelo componente pai (inbox page) pois favoritos são estado local
const handleMenuAction = useCallback((
action: string,
chat: NewWhatsChat,
onFavoriteToggle?: (jid: string) => void
) => {
const chatId = String(chat.id)
switch (action) {
case 'archive': handleArchiveChat(chatId, !chat.is_archived); break
case 'pin': handlePinChat(chatId, !chat.is_pinned); break
case 'mark_read': handleMarkRead(chatId); break
case 'delete': handleDeleteChat(chatId); break
case 'favorite': onFavoriteToggle?.(chat.remote_jid); break
}
}, [handleArchiveChat, handlePinChat, handleMarkRead, handleDeleteChat])
return {
instances: instancesLegacy,
activeInstance: activeInstanceLegacy,
chats: chatsLegacy,
selectedChat: selectedChatLegacy,
messages: messagesLegacy,
loadingChats: chatStore.chatsLoading,
loadingMessages: chatStore.messagesLoading,
activeInstanceId: instanceStore.activeInstanceId,
qrByInstance: instanceStore.qrByInstance,
setActiveInstance: handleSetActiveInstance,
refreshInstances: handleRefreshInstances,
handleSelectChat,
handleSendText,
handleLoadMoreHistory,
handleConnectInstance,
handleArchiveChat,
handlePinChat,
handleDeleteChat,
handleMarkRead,
handleSearchMessages,
handleMenuAction,
presenceByJid: {} as Record<string, any>,
isFavoriteChat: (_: NewWhatsChat) => false,
}
}