feat(settings): selo Conta Business, aviso de re-scan ao trocar API, log de erro de botões

- Detecta WhatsApp Business (app) via getBusinessProfile no pós-conexão e
  persiste em instances.isBusiness; painel mostra "Conta Business: sim/não"
  (com nota Business App ≠ Business API).
- Painel avisa que trocar de API desconecta e exige escanear o QR novamente,
  com confirmação antes de aplicar a troca.
- sendRichMessage agora monitora e loga erros especificamente quando a mensagem
  tem botões/lista/carrossel (diagnóstico de rejeição de interativos por engine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-28 05:45:34 +02:00
parent c801d8d820
commit 43f519e7aa
4 changed files with 88 additions and 3 deletions
@@ -89,6 +89,7 @@ model Instance {
avatar String? avatar String?
status InstanceStatus @default(DISCONNECTED) status InstanceStatus @default(DISCONNECTED)
engine String @default("infinite") engine String @default("infinite")
isBusiness Boolean @default(false) // conta WhatsApp Business (app) — detectada via getBusinessProfile
sessionPath String? sessionPath String?
webhookUrl String? webhookUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -260,6 +260,9 @@ export class ContactHandler {
*/ */
async syncInstanceAvatar(instanceId: string): Promise<string | null> { async syncInstanceAvatar(instanceId: string): Promise<string | null> {
if (!this.sock?.user?.id) return null if (!this.sock?.user?.id) return null
// Detecta se a conta é WhatsApp Business (app) — informativo no painel.
// Nota: é o Business APP, não a Business API; botões só são confiáveis na API.
this.syncBusinessStatus(instanceId).catch(() => {})
try { try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image') const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image')
if (url) { if (url) {
@@ -273,6 +276,25 @@ export class ContactHandler {
return null return null
} }
/**
* Verifica se a conta conectada é WhatsApp Business (app) via getBusinessProfile
* e persiste em instances.isBusiness. Falha silenciosa = conta pessoal.
*/
async syncBusinessStatus(instanceId: string): Promise<void> {
if (!this.sock?.user?.id) return
let isBusiness = false
try {
const profile = await (this.sock as any).getBusinessProfile?.(this.sock.user.id)
isBusiness = !!profile && (profile.description != null || profile.category != null || profile.email != null || profile.website != null)
} catch {
isBusiness = false
}
try {
await prisma.instance.update({ where: { id: instanceId }, data: { isBusiness } })
logger.info({ instanceId, isBusiness }, '[ContactHandler] Status Business da instância atualizado')
} catch { /* não-fatal */ }
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME // RECONCILIAÇÃO DE NOMES VIA PUSHNAME
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -403,7 +425,10 @@ export class ContactHandler {
let ok = 0 let ok = 0
for (const collection of collections) { for (const collection of collections) {
try { try {
await this.sock.resyncAppState([collection] as any, false) // isInitial=true → full snapshot da collection (baixa o estado ATUAL,
// incluindo pins/arquivos já existentes). Com false (incremental) o WA
// não reenvia o que já está no hash, então fixados existentes não vêm.
await this.sock.resyncAppState([collection] as any, true)
ok++ ok++
logger.info({ instanceId: this.instanceId, collection }, logger.info({ instanceId: this.instanceId, collection },
'[ContactHandler] app-state sincronizado (pin/arquivo/mute)') '[ContactHandler] app-state sincronizado (pin/arquivo/mute)')
@@ -29,6 +29,7 @@
* mas testamos que a forma direta com nó biz > list também funciona. * mas testamos que a forma direta com nó biz > list também funciona.
*/ */
import { generateMessageIDV2, generateWAMessageFromContent, isJidGroup, isJidStatusBroadcast } from './engine' import { generateMessageIDV2, generateWAMessageFromContent, isJidGroup, isJidStatusBroadcast } from './engine'
import { logger } from '../../config/logger'
import { proto } from './engine' import { proto } from './engine'
import type { WASocket, BinaryNode } from './engine' import type { WASocket, BinaryNode } from './engine'
@@ -212,6 +213,31 @@ export async function sendRichMessage(
payload: RichPayload, payload: RichPayload,
quoted?: { key: any; message: any }, quoted?: { key: any; message: any },
engineType?: string, engineType?: string,
): Promise<string> {
// Monitora erros ESPECIFICAMENTE quando a mensagem tem botões/lista/carrossel,
// para diagnosticar quando o WhatsApp rejeita a renderização de mensagens
// interativas (varia por engine e por tipo de conta).
const hasInteractive = !!(payload.nativeButtons || payload.nativeList || payload.nativeCarousel)
const eng = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite'
try {
return await sendRichMessageInner(sock, jid, payload, quoted, engineType)
} catch (err) {
if (hasInteractive) {
const tipo = payload.nativeButtons ? 'botões' : payload.nativeList ? 'lista' : 'carrossel'
logger.error({ err, jid, engine: eng, tipo },
`[rich-message] FALHA ao enviar mensagem interativa (${tipo}) — engine="${eng}". ` +
`Botões podem não ser suportados nesta conta/engine.`)
}
throw err
}
}
async function sendRichMessageInner(
sock: WASocket,
jid: string,
payload: RichPayload,
quoted?: { key: any; message: any },
engineType?: string,
): Promise<string> { ): Promise<string> {
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload
const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite' const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite'
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { ShieldCheck, LayoutGrid, Check, Loader2, Tag } from 'lucide-react'; import { ShieldCheck, LayoutGrid, Check, Loader2, Tag, AlertTriangle, Briefcase } from 'lucide-react';
interface SettingsPanelProps { interface SettingsPanelProps {
instanceId: string | null; instanceId: string | null;
@@ -28,6 +28,7 @@ const ENGINES: {
export default function SettingsPanel({ instanceId }: SettingsPanelProps) { export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const [engine, setEngine] = useState<string | null>(null); const [engine, setEngine] = useState<string | null>(null);
const [isBusiness, setIsBusiness] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null); const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -56,6 +57,7 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
.then((list) => { .then((list) => {
const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null; const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null;
setEngine(inst?.engine ?? null); setEngine(inst?.engine ?? null);
setIsBusiness(inst ? !!inst.isBusiness : null);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -63,6 +65,12 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const selectEngine = useCallback(async (value: string) => { const selectEngine = useCallback(async (value: string) => {
if (!instanceId || saving || value === engine) return; if (!instanceId || saving || value === engine) return;
// Trocar de engine invalida a sessão (credenciais não migram entre APIs):
// confirma porque exige escanear o QR Code novamente.
if (typeof window !== 'undefined' && !window.confirm(
'Trocar de API vai DESCONECTAR a sessão atual.\n\n' +
'Você precisará escanear o QR Code novamente para reconectar. Continuar?'
)) return;
setSaving(value); setSaving(value);
setError(null); setError(null);
try { try {
@@ -88,7 +96,14 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
<div className="relative z-[1] flex-1 h-full overflow-y-auto bg-[#f0f2f5] p-6 md:p-10"> <div className="relative z-[1] flex-1 h-full overflow-y-auto bg-[#f0f2f5] p-6 md:p-10">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-light text-[#41525d] mb-2">Configurações</h1> <h1 className="text-3xl font-light text-[#41525d] mb-2">Configurações</h1>
<p className="text-base text-[#667781] mb-8">Escolha a API de conexão do WhatsApp para esta instância.</p> <p className="text-base text-[#667781] mb-4">Escolha a API de conexão do WhatsApp para esta instância.</p>
<div className="flex items-start gap-3 rounded-xl bg-amber-50 border border-amber-200 p-4 mb-6">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-[#5b4b1f]">
Trocar de API <strong>desconecta a sessão</strong> será necessário <strong>escanear o QR Code novamente</strong> para reconectar.
</p>
</div>
{!instanceId && ( {!instanceId && (
<p className="text-base text-[#667781] mb-6">Selecione uma instância primeiro.</p> <p className="text-base text-[#667781] mb-6">Selecione uma instância primeiro.</p>
@@ -136,6 +151,24 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
{loading && <p className="text-sm text-[#8696a0] mb-4">Carregando configuração atual</p>} {loading && <p className="text-sm text-[#8696a0] mb-4">Carregando configuração atual</p>}
{error && <p className="text-sm text-red-500 mb-4">{error}</p>} {error && <p className="text-sm text-red-500 mb-4">{error}</p>}
{/* ── Conta Business ──────────────────────────────────────── */}
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8 mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
<Briefcase className="w-8 h-8 text-[#54656f]" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold text-[#111b21]">Conta Business</h2>
<p className="text-xl font-medium text-[#41525d] mt-1">
{isBusiness === null ? '—' : isBusiness ? 'Sim (WhatsApp Business)' : 'Não (conta pessoal)'}
</p>
<p className="text-xs text-[#8696a0] mt-1">
Business App Business API botões são garantidos na Business API oficial.
</p>
</div>
</div>
</div>
{/* ── Versão ──────────────────────────────────────────────── */} {/* ── Versão ──────────────────────────────────────────────── */}
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8"> <div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">