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?
|
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())
|
||||||
|
|||||||
+26
-1
@@ -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ó 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user