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:
@@ -89,6 +89,7 @@ model Instance {
|
||||
avatar String?
|
||||
status InstanceStatus @default(DISCONNECTED)
|
||||
engine String @default("infinite")
|
||||
isBusiness Boolean @default(false) // conta WhatsApp Business (app) — detectada via getBusinessProfile
|
||||
sessionPath String?
|
||||
webhookUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
+26
-1
@@ -260,6 +260,9 @@ export class ContactHandler {
|
||||
*/
|
||||
async syncInstanceAvatar(instanceId: string): Promise<string | 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 {
|
||||
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image')
|
||||
if (url) {
|
||||
@@ -273,6 +276,25 @@ export class ContactHandler {
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -403,7 +425,10 @@ export class ContactHandler {
|
||||
let ok = 0
|
||||
for (const collection of collections) {
|
||||
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++
|
||||
logger.info({ instanceId: this.instanceId, collection },
|
||||
'[ContactHandler] app-state sincronizado (pin/arquivo/mute)')
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
* mas testamos que a forma direta com nó biz > list também funciona.
|
||||
*/
|
||||
import { generateMessageIDV2, generateWAMessageFromContent, isJidGroup, isJidStatusBroadcast } from './engine'
|
||||
import { logger } from '../../config/logger'
|
||||
import { proto } from './engine'
|
||||
import type { WASocket, BinaryNode } from './engine'
|
||||
|
||||
@@ -212,6 +213,31 @@ export async function sendRichMessage(
|
||||
payload: RichPayload,
|
||||
quoted?: { key: any; message: any },
|
||||
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> {
|
||||
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload
|
||||
const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
instanceId: string | null;
|
||||
@@ -28,6 +28,7 @@ const ENGINES: {
|
||||
|
||||
export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
|
||||
const [engine, setEngine] = useState<string | null>(null);
|
||||
const [isBusiness, setIsBusiness] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -56,6 +57,7 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
|
||||
.then((list) => {
|
||||
const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null;
|
||||
setEngine(inst?.engine ?? null);
|
||||
setIsBusiness(inst ? !!inst.isBusiness : null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
@@ -63,6 +65,12 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
|
||||
|
||||
const selectEngine = useCallback(async (value: string) => {
|
||||
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);
|
||||
setError(null);
|
||||
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="max-w-2xl mx-auto">
|
||||
<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 && (
|
||||
<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>}
|
||||
{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ó são garantidos na Business API oficial.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Versão ──────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
Reference in New Issue
Block a user