Consentimento e opt-in da carteira já confirmados.
} ++ Envio sem confirmação repetitiva: ao iniciar, o gestor utiliza a autorização contratual + declarada para a carteira desta empresa. O sistema ainda exclui números inválidos, + processos encerrados, retornos sem novidade e contatos com recusa expressa ou SAIR. +
{kind==='publication' ? 'Publicações: até 25 mensagens por dia, intervalo mínimo de 3 minutos, só em horário comercial de dias úteis, no máximo uma por número a cada 24h. Esses controles não garantem ausência de bloqueio; a política do WhatsApp e os modelos aprovados quando exigidos continuam obrigatórios.' @@ -262,9 +267,9 @@ export function MovementCampaignControl() {
Pause ou conclua a fila em andamento antes de iniciar uma nova.
} diff --git a/src/components/whatsapp/wa-daily-return-panel.tsx b/src/components/whatsapp/wa-daily-return-panel.tsx index fb956ad2..184a4c9d 100644 --- a/src/components/whatsapp/wa-daily-return-panel.tsx +++ b/src/components/whatsapp/wa-daily-return-panel.tsx @@ -103,7 +103,7 @@ export function WaDailyReturnPanel(){ {[1,3,7,14,30].map(n=>)}Scanner de retornos · um processo por vez
@@ -112,7 +112,7 @@ export function WaDailyReturnPanel(){Verificados nesta sessão: {checked}. A varredura manual continua enquanto esta aba permanecer aberta. O scanner da carteira também prepara avisos quando a automação está ativa.
+Verificados nesta sessão: {checked}. A verificação ocorre um processo por vez. O total vencido inclui casos encerrados, sem telefone e sem andamento novo; nenhum deles é disparado sem validação. O scanner da carteira também prepara avisos apenas quando a automação está ativa.
Verificar apenas um processo
diff --git a/src/lib/wa-auto-client.ts b/src/lib/wa-auto-client.ts index 64da57b7..97a4b451 100644 --- a/src/lib/wa-auto-client.ts +++ b/src/lib/wa-auto-client.ts @@ -341,20 +341,47 @@ export async function logoutWaAuto() { } /** Already-authorized background identity; never accepts identity from a browser argument. */ +/** + * WA.Auto Cloud expects the shared integration token plus x-lexis-user-id. + * The older lexishwa1 HMAC credential is not accepted by its integration + * middleware and caused every server-side queue send to return HTTP 401. + * Caller resolves company ownership and management permission before here. + */ export async function waAutoForOwner(userId:string,empresaId:string,to?:string,message?:string) { const cfg=getWaAutoConfig(); + const validId=/^[0-9a-f-]{36}$/i; + if(!validId.test(userId)||!validId.test(empresaId))return { + ok:false as const,rejected:true,httpStatus:403,error:'Responsável pela sessão não identificado.' + }; + if(!cfg.integrationToken)return { + ok:false as const,rejected:true,httpStatus:503, + error:'WA_INTEGRATION_TOKEN não configurado no LexisPredict para envios do servidor.' + }; try { - const {signWaWorkerIdentity}=await import('@/lib/wa-worker-auth'); - const token=signWaWorkerIdentity(userId,empresaId,cfg.integrationToken); const response=await fetch(cfg.baseUrl+'/api/integrations/lexispredict/'+(to?'send':'status'),{ - method:to?'POST':'GET',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'}, - ...(to?{body:JSON.stringify({to,message})}:{}),cache:'no-store',signal:AbortSignal.timeout(to?40000:12000), + method:to?'POST':'GET', + headers:{ + Authorization:'Bearer '+cfg.integrationToken, + 'x-wa-integration-token':cfg.integrationToken, + 'x-lexis-user-id':userId, + 'Content-Type':'application/json', + }, + ...(to?{body:JSON.stringify({to,message})}:{}), + cache:'no-store', + signal:AbortSignal.timeout(to?40000:12000), }); const body=await readJson(response); - if(!response.ok||body?.ok===false)return {ok:false as const,rejected:response.status>=400&&response.status<500, - httpStatus:response.status,error:String(body?.error||'WA.Auto HTTP '+response.status),raw:body}; - if(!to && body?.connection?.status!=='ready')return {ok:false as const,rejected:true, - httpStatus:409,error:'WhatsApp do responsável desconectado',raw:body}; + if(!response.ok||body?.ok===false)return { + ok:false as const,rejected:response.status>=400&&response.status<500, + httpStatus:response.status, + error:response.status===401 + ? 'WA.Auto rejeitou a credencial do servidor (HTTP 401). Confira WA_INTEGRATION_TOKEN em ambas as plataformas; a campanha foi bloqueada.' + : String(body?.error||'WA.Auto HTTP '+response.status),raw:body, + }; + if(!to && body?.connection?.status!=='ready')return { + ok:false as const,rejected:true,httpStatus:409, + error:'WhatsApp do responsável desconectado no WA.Auto.',raw:body, + }; return {ok:true as const,raw:body}; }catch(e:any){return {ok:false as const,rejected:false,error:String(e?.message||'WA.Auto indisponível')};} } diff --git a/src/lib/wa-movement-campaign.ts b/src/lib/wa-movement-campaign.ts index b24e647f..6e1e68af 100644 --- a/src/lib/wa-movement-campaign.ts +++ b/src/lib/wa-movement-campaign.ts @@ -5,7 +5,7 @@ import 'server-only'; import { getSupabaseAdmin, getUserContext } from '@/lib/server-db'; import { resolveWaAutoPermissions } from '@/lib/wa-auto-permissions'; -import { waAutoHealth, waAutoForOwner, sendViaWaAuto } from '@/lib/wa-auto-client'; +import { waAutoForOwner, sendViaWaAuto } from '@/lib/wa-auto-client'; import { persistWhatsAppMessage } from '@/lib/whatsapp-persist'; import { prepareMovementAlert, type SourceRow, type Alert } from '@/lib/wa-movement-builder'; import {getDailyReturnSettings,upsertDailyReturnSettings} from '@/lib/wa-daily-return-service'; @@ -31,7 +31,7 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement', alreadyClosed:0, consentMissing:0, needsReview:0, alreadyNotified:0, missingReturn:0, noNewMovement:0 }; let after=0; for (;;) { - const {data,error}=await db.rpc('wa_notice_portfolio_rows',{p_empresa:empresaId,p_after:after,p_limit:600}); + const {data,error}=await db.rpc('wa_notice_portfolio_rows',{p_empresa:empresaId,p_after:after,p_limit:3000}); if(error)throw new Error('Carteira: '+error.message); const rows=(data||[]) as SourceRow[]; for(const row of rows) { @@ -48,7 +48,7 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement', else counts.withoutEvent++; } else entries.push(alert); } - if(rows.length<600)break; + if(rows.length<3000)break; after=Number(rows[rows.length-1].id); } return {entries,counts}; @@ -58,10 +58,14 @@ export async function previewMovementCampaign(kind:CampaignKind='movement') { try { const ctx = await requireManager(); const settings=await getDailyReturnSettings(ctx.empresa_id); - const { entries, counts } = await collectPortfolio(ctx.empresa_id,kind,settings.consentAttested); const db = await getSupabaseAdmin(); + const [portfolio, priorResponse] = await Promise.all([ + collectPortfolio(ctx.empresa_id,kind,true), // prévia sob autorização contratual a registrar pelo gestor ao iniciar + db.rpc('wa_notice_prior',{p_empresa:ctx.empresa_id}), + ]); + const {entries,counts}=portfolio; // Preview excludes already queued/sent versions (database uniqueness also guards races). - const {data,error} = await db.rpc('wa_notice_prior',{p_empresa:ctx.empresa_id}); + const {data,error}=priorResponse; if (error) throw new Error('Fila não instalada no Supabase. Aplique a migração: ' + error.message); const prior = new Set((data || []).map((x:{processo_id:number;event_hash:string}) => x.processo_id + ':' + x.event_hash)); const pending = entries.filter(x => !prior.has(x.processo_id + ':' + x.event_hash)); @@ -82,7 +86,9 @@ export async function createMovementCampaign(confirmed: boolean,kind:CampaignKin const settings=await getDailyReturnSettings(ctx.empresa_id); if (!settings.consentAttested && confirmed !== true) throw new Error('Confirme previamente a autorização dos contatos de acompanhamento.'); - const health = await waAutoHealth(); + // Health must use the SAME server credential as the background dispatch. + // A browser-connected session alone did not validate automated sends (401). + const health = await waAutoForOwner(ctx.auth_id,ctx.empresa_id); if (!health.ok) throw new Error('Conecte o WA.Auto antes de iniciar. ' + (health.error || 'Sessão indisponível')); const db=await getSupabaseAdmin(); const {data: existing} = await db.from('wa_movement_campaigns').select('id') @@ -102,13 +108,15 @@ export async function createMovementCampaign(confirmed: boolean,kind:CampaignKin if (createErr || !campaign) throw new Error(createErr?.message || 'Não foi possível criar a campanha.'); let inserted=0; try { - for(let offset=0;offset