$rec,'last'=>null,'ts'=>0]; return ['step'=>0,'last'=>null,'ts'=>0]; } /* Idempotência por evento (leadId|chave) – usa arquivo EVENTS_CACHE */ function log_once($leadId,$eventKey){ if(!$leadId) return false; list($db,$H)=jLoadLock(EVENTS_CACHE); $k=$leadId.'|'.$eventKey; if(isset($db[$k])){ jSaveUnlock($H, EVENTS_CACHE, $db); return false; } $db[$k]=time(); jSaveUnlock($H, EVENTS_CACHE, $db); return true; } /* ── Anti-duplicação global de mensagens OUTBOUND (lock-safe) ── */ function canSendMessage($fone, $msgKey, &$state = null, $STATE_H = null) { $now = time(); if ($state !== null && $STATE_H !== null) { $rec = getRec($state, $fone); $lastKey = $rec['last_msg_key'] ?? null; $lastTs = (int)($rec['last_msg_ts'] ?? 0); if ($lastKey === $msgKey && ($now - $lastTs) < GLOBAL_REPLY_TTL) return false; $rec['last_msg_key'] = $msgKey; $rec['last_msg_ts'] = $now; $state[$fone] = $rec; return true; } list($db, $H) = jLoadLock(STATE_FILE); $rec = getRec($db, $fone); $lastKey = $rec['last_msg_key'] ?? null; $lastTs = (int)($rec['last_msg_ts'] ?? 0); if ($lastKey === $msgKey && ($now - $lastTs) < GLOBAL_REPLY_TTL) { jSaveUnlock($H, STATE_FILE, $db); return false; } $rec['last_msg_key']=$msgKey; $rec['last_msg_ts']=$now; $db[$fone]=$rec; jSaveUnlock($H, STATE_FILE, $db); return true; } function sendTxtOnce($fone, $msg, $tag='generic', &$state = null, $STATE_H = null){ $key = md5($tag.'|'.$msg); if (canSendMessage($fone, $key, $state, $STATE_H)) { zapi('/send-text',["phone"=>$fone,"message"=>$msg]); error_log("sendTxtOnce OK [{$tag}] to {$fone}"); } else { error_log("sendTxtOnce SKIP duplicate [{$tag}] to {$fone}"); } } /* ─────────────────────── Z-API ─────────────────────── */ function zapi($path,array $json){ $u="https://api.z-api.io/instances/".ZAPI_ID."/token/".ZAPI_TOKEN.$path; $c=curl_init($u); $hdr=["Content-Type: application/json"]; if(ZAPI_CLIENTTOK){ $hdr[]="Client-Token: ".ZAPI_CLIENTTOK; } curl_setopt_array($c,[ CURLOPT_POST=>1, CURLOPT_RETURNTRANSFER=>1, CURLOPT_CONNECTTIMEOUT=>7, CURLOPT_TIMEOUT=>20, CURLOPT_HTTPHEADER=>$hdr, CURLOPT_POSTFIELDS=>json_encode($json, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) ]); $body=curl_exec($c); $err = ($body===false)? curl_error($c): null; $http=curl_getinfo($c,CURLINFO_HTTP_CODE); curl_close($c); $preview = is_string($body) ? substr($body,0,300) : ''; error_log("ZAPI CALL path={$path} http={$http} err=".($err?:'none')." req=".json_encode($json)." resp={$preview}"); if($http!=200 || $err){ return [false,$http,$body]; } return [true,$http,$body]; } /* ─────────────────────── Bitrix ─────────────────────── */ function b24_base(){ return preg_replace('~/crm\.lead\.add\.json$~','', BITRIX_ADD_URL); } function b24_call($method, array $params=[], $timeout=10, $retries=2){ $url = rtrim(b24_base(), '/').'/'.$method.'.json'; $payload = json_encode($params, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); $attempt=0; $lastErr=null; do{ $ch=curl_init($url); curl_setopt_array($ch,[ CURLOPT_RETURNTRANSFER=>1, CURLOPT_POST=>1, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_TIMEOUT=>$timeout, CURLOPT_HTTPHEADER=>['Content-Type: application/json'], CURLOPT_POSTFIELDS=>$payload ]); $resp=curl_exec($ch); $http=curl_getinfo($ch,CURLINFO_HTTP_CODE); $err=curl_error($ch); curl_close($ch); if($http>=200 && $http<300 && $resp){ $j=json_decode($resp,true); if(!isset($j['error'])) return $j; $lastErr=$j['error'].': '.($j['error_description']??''); }else{ $lastErr="HTTP $http err=".($err?:'none')." body=".$resp; } usleep((150+250*$attempt)*1000); $attempt++; }while($attempt<=$retries); throw new \RuntimeException("Bitrix call fail ($method): $lastErr"); } function b24_lead_add(array $fields){ return b24_call('crm.lead.add',['fields'=>$fields]); } function b24_findLeadIdByPhone($phone){ try{ $j = b24_call('crm.duplicate.findbycomm', [ 'entity_type' => 'LEAD', 'type' => 'PHONE', 'values' => [ (string)$phone ] ]); $ids = $j['result']['LEAD'] ?? []; if (!$ids) return null; sort($ids, SORT_NUMERIC); return (int)end($ids); } catch (Exception $e) { error_log('findLeadByPhone: '.$e->getMessage()); return null; } } function b24_timeline_comment_lead($leadId, $comment){ if(!$leadId || !$comment) return false; try{ $res = b24_call('crm.timeline.comment.add', [ 'fields' => [ 'ENTITY_TYPE' => 'LEAD', // <-- FIX importante (antes estava "lead") 'ENTITY_ID' => (int)$leadId, 'COMMENT' => (string)$comment, ] ], 10, 1); error_log('timeline.comment.add OK lead='.$leadId.' resp='.json_encode($res)); return true; } catch (Exception $e) { error_log('timeline.comment.add FAIL lead='.$leadId.' err='.$e->getMessage()); return false; } } function b24_activity_note_lead($leadId, $note){ if(!$leadId || !$note) return false; try{ $res = b24_call('crm.activity.add', [ 'fields' => [ 'OWNER_TYPE_ID' => 1, 'OWNER_ID' => (int)$leadId, 'TYPE_ID' => 6, 'SUBJECT' => 'WhatsApp - Mensagem recebida', 'DESCRIPTION' => (string)$note, 'DESCRIPTION_TYPE' => 3, 'DIRECTION' => 2, 'COMPLETED' => 'Y', 'PRIORITY' => 2, 'PROVIDER_ID' => 'WHATSAPP', 'PROVIDER_TYPE_ID' => 'ZAPI', ] ], 10, 1); error_log('crm.activity.add OK lead='.$leadId.' resp='.json_encode($res)); return true; } catch (Exception $e) { error_log('crm.activity.add FAIL lead='.$leadId.' err='.$e->getMessage()); return false; } } function b24_append_comments_lead($leadId, $text){ if(!$leadId || !$text) return false; try{ $get = b24_call('crm.lead.get', ['id'=>(int)$leadId], 10, 1); $cur = (string)($get['result']['COMMENTS'] ?? ''); $new = trim($cur . "\n\n" . $text); $res = b24_call('crm.lead.update', [ 'id' => (int)$leadId, 'fields' => ['COMMENTS' => $new] ], 10, 1); error_log('crm.lead.update COMMENTS OK lead='.$leadId.' resp='.json_encode($res)); return true; } catch (Exception $e) { error_log('crm.lead.update COMMENTS FAIL lead='.$leadId.' err='.$e->getMessage()); return false; } } /* ─────────────────────── Intent detect (apenas no 1º contato) ─────────────────────── */ function detect_intent($txt){ $t = strtolower($txt); if (strpos($t, 'ital') !== false) return 'IT'; if (strpos($t, 'portug') !== false) return 'PT'; return 'UNK'; } /* ─────────────────────── Helpers: message id + tipo ─────────────────────── */ function get_message_id($d, $fone, $incomingTextRaw){ $candidates = [ $d['messageId'] ?? null, $d['message_id'] ?? null, $d['messages'][0]['id'] ?? null, $d['message']['id'] ?? null, $d['message']['messageId'] ?? null, $d['text']['id'] ?? null, ]; foreach($candidates as $c){ if(is_string($c) && strlen($c) > 6) return $c; } $ts = $d['timestamp'] ?? ($d['message']['timestamp'] ?? ($d['messages'][0]['timestamp'] ?? '')); $type = $d['type'] ?? ($d['message']['type'] ?? ($d['messages'][0]['type'] ?? '')); return 'h_'.substr(sha1($fone.'|'.(string)$ts.'|'.(string)$type.'|'.(string)$incomingTextRaw),0,24); } function get_inbound_kind($d, $incomingText){ if($incomingText !== '') return 'texto'; $type = strtolower((string)($d['type'] ?? ($d['message']['type'] ?? ($d['messages'][0]['type'] ?? '')))); if(strpos($type,'audio')!==false || strpos($type,'ptt')!==false || isset($d['audio'])) return 'audio'; if(strpos($type,'image')!==false || isset($d['image'])) return 'imagem'; if(strpos($type,'video')!==false || isset($d['video'])) return 'video'; if(strpos($type,'document')!==false || isset($d['document'])) return 'documento'; return 'midia'; } /* ─────────────────────── FIX #1: Ignorar status/outbound ─────────────────────── */ function is_outbound_or_status($d){ if(isset($d['fromMe']) && $d['fromMe'] === true) return true; if(isset($d['message']['fromMe']) && $d['message']['fromMe'] === true) return true; if(isset($d['messages'][0]['fromMe']) && $d['messages'][0]['fromMe'] === true) return true; $ev = strtolower((string)($d['event'] ?? $d['type'] ?? ($d['message']['type'] ?? ''))); if($ev){ foreach(['status','ack','delivery','read','sent','outgoing','outbound'] as $k){ if(strpos($ev,$k) !== false) return true; } } return false; } /* ─────────────────────── FIX #2: Resolver telefone robusto (anti “código”) ─────────────────────── */ function digits($v){ if($v === null) return ''; return preg_replace('/\D/','',(string)$v); } /** * Prioriza campos “de remetente” e valida formato BR. * NÃO usa $d['phone'] como primeira opção (é justamente onde aparece “código” em alguns eventos). */ function resolve_phone($d){ $cands = [ $d['senderPhone'] ?? null, $d['message']['phone'] ?? null, $d['messages'][0]['from'] ?? null, $d['from'] ?? null, $d['phone'] ?? null, // por último ]; foreach($cands as $c){ $x = digits($c); if($x === '') continue; // validação anti “código” if(ACCEPT_ONLY_BR){ if(strpos($x,'55') !== 0) continue; // BR típico: 55 + DDD + número => 12 ou 13 dígitos if(strlen($x) < 12 || strlen($x) > 13) continue; }else{ if(strlen($x) < 10 || strlen($x) > 15) continue; } return $x; } return ''; } /* ─────────────────────── Recepção ─────────────────────── */ $raw = file_get_contents('php://input'); $d = json_decode($raw,true) ?: []; if(!$d){ error_log('EMPTY/INVALID JSON payload: '.substr($raw,0,500)); } /* Ignora status/outbound */ if(IGNORE_STATUS_EVENTS && is_outbound_or_status($d)){ resp('ignored-status-or-outbound'); } /* Telefone */ $fone = resolve_phone($d); if(!$fone){ http_response_code(400); error_log('NO VALID PHONE | payload='.substr($raw,0,900)); resp('no-valid-phone'); } /* Texto (pode vir vazio em mídia/áudio) */ $incomingTextRaw = $d['text']['message'] ?? ($d['message']['text'] ?? ''); $incomingText = strtolower(trim((string)$incomingTextRaw)); /* Id e tipo do inbound */ $msgId = get_message_id($d, $fone, $incomingTextRaw); $kind = get_inbound_kind($d, $incomingText); /* Estado com LOCK */ list($state, $STATE_H) = jLoadLock(STATE_FILE); $rec = getRec($state,$fone); /* Reset */ if (in_array($incomingText, ['reiniciar','recomecar','recomeçar','inicio','início'], true)) { $curStep = (int)($rec['step'] ?? 0); if ($curStep <= 1) { $rec['step'] = 0; $rec['intent'] = null; $rec['session_started'] = false; $state[$fone] = $rec; jSaveUnlock($STATE_H, STATE_FILE, $state); resp('reset'); } jSaveUnlock($STATE_H, STATE_FILE, $state); resp('reset-ignored-human'); } /* ─────────────────────── Resolver LeadId priorizando cache local ─────────────────────── */ $leadId = 0; /* 1) Cache leadid_by_phone */ $map = jLoad(LEADID_BY_PHONE); if(is_array($map) && isset($map[(string)$fone])) $leadId = (int)$map[(string)$fone]; /* 2) Estado */ if(!$leadId) $leadId = (int)($rec['lead_id'] ?? 0); /* 3) Cache de phones registrados */ $cache = jLoad(LEADS_CACHE); if(!is_array($cache)) $cache = []; /* 4) Se não encontrou nada local, pergunta pro Bitrix */ if(!$leadId){ $leadIdFound = b24_findLeadIdByPhone($fone); if ($leadIdFound) { $leadId = (int)$leadIdFound; list($mapW, $Hmap) = jLoadLock(LEADID_BY_PHONE); $mapW[(string)$fone] = $leadId; jSaveUnlock($Hmap, LEADID_BY_PHONE, $mapW); } } $alreadyRegistered = in_array($fone, $cache, true) || ($leadId > 0); /* ─────────────────────── Se já existe: MANUAL, mas registra inbound no Bitrix ─────────────────────── */ if ($alreadyRegistered) { if($leadId && log_once($leadId, "in:$msgId")){ $comment = "📩 *Mensagem recebida (WhatsApp)*\n". "Tipo: {$kind}\n". "Texto: ".($incomingTextRaw !== '' ? (string)$incomingTextRaw : "[sem texto]"); $ok = b24_timeline_comment_lead($leadId, $comment); if(!$ok) $ok = b24_activity_note_lead($leadId, $comment); if(!$ok) b24_append_comments_lead($leadId, $comment); } $rec['lead_id'] = $leadId ?: ($rec['lead_id'] ?? null); $rec['step'] = 2; $rec['ts'] = nowts(); $state[$fone] = $rec; jSaveUnlock($STATE_H, STATE_FILE, $state); resp('existing-manual-logged'); } /* ─────────────────────── Primeiro contato REAL: cria lead e envia 1 msg (se houver texto) ─────────────────────── */ $now = nowts(); /* Cria lead no Bitrix */ try{ $commentsFirst = ($incomingTextRaw !== '' ? (string)$incomingTextRaw : "[Entrada sem texto: {$kind}]"); $j=b24_lead_add([ 'NAME' => $d['senderName'] ?? 'Novo contato', 'TITLE' => $d['senderName'] ?? 'Novo contato', 'PHONE' => [['VALUE' => $fone, 'VALUE_TYPE' => 'MOBILE']], 'COMMENTS' => $commentsFirst, 'ASSIGNED_BY_ID' => DEFAULT_ASSIGNED_BY_ID, ]); $leadId=(int)($j['result']??0); if(!$leadId){ $leadId = (int)(b24_findLeadIdByPhone($fone) ?: 0); } }catch(Exception $e){ error_log("lead.add fail: ".$e->getMessage()); $leadId = 0; } /* Atualiza mapa fone->leadId */ if($leadId){ list($mapW,$Hmap) = jLoadLock(LEADID_BY_PHONE); $mapW[(string)$fone]=(int)$leadId; jSaveUnlock($Hmap, LEADID_BY_PHONE, $mapW); } /* Salva no cache de leads registrados */ list($cacheW,$Hc) = jLoadLock(LEADS_CACHE); if(!in_array($fone,$cacheW,true)){ $cacheW[]=$fone; } jSaveUnlock($Hc, LEADS_CACHE, $cacheW); /* Loga inbound na timeline também — com dedup */ if($leadId && log_once($leadId, "in:$msgId")){ $comment = "📩 *Mensagem recebida (WhatsApp)*\n". "Tipo: {$kind}\n". "Texto: ".($incomingTextRaw !== '' ? (string)$incomingTextRaw : "[sem texto]"); b24_timeline_comment_lead($leadId, $comment); } /* Se veio mídia/áudio sem texto no 1º contato: NÃO automatiza resposta */ if ($incomingText === '') { $rec['lead_id'] = $leadId ?: null; $rec['step'] = 2; $rec['ts'] = $now; $state[$fone] = $rec; jSaveUnlock($STATE_H, STATE_FILE, $state); resp('first-contact-media-manual-logged'); } /* Intent */ $intent = detect_intent($incomingText); /* Envia UMA msg automática no 1º contato e vira manual */ if ($intent === 'PT') { sendTxtOnce($fone, MSG_INIT_PT, 'init_pt', $state, $STATE_H); } elseif ($intent === 'IT') { sendTxtOnce($fone, MSG_INIT_IT, 'init_it', $state, $STATE_H); } else { // Mantive seu padrão original: // sendTxtOnce($fone, MSG_INIT_UNI, 'init_uni', $state, $STATE_H); // Se você quiser que responda SEMPRE, descomente a linha acima. } /* MODO HUMANO */ $rec = getRec($state,$fone); $rec['lead_id'] = $leadId ?: null; $rec['intent'] = $intent; $rec['session_started'] = true; $rec['step'] = 2; $rec['ts'] = $now; $state[$fone] = $rec; jSaveUnlock($STATE_H, STATE_FILE, $state); resp('first-contact-auto-then-human-logged');