diff --git a/api/webhook.php b/api/webhook.php index fc974b6..179c009 100644 --- a/api/webhook.php +++ b/api/webhook.php @@ -129,13 +129,25 @@ class WhatsAppWebhook { foreach ($items as $message) { // En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos. - // `from`/`wa_id` dejaron de ser obligatorios: si la persona oculta su teléfono - // solo llega `from_user_id` (BSUID, ej. "CO.1761088155094242"). Ese identificador - // ocupa el lugar del teléfono en todo el flujo y sirve para responderle. - $phoneNumber = $message['from'] ?? ($message['wa_id'] ?? ($message['from_user_id'] ?? null)); + $phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null); $messageId = $message['id'] ?? ($message['message_id'] ?? null); $timestamp = $message['timestamp'] ?? null; + // Meta manda el BSUID en todos los mensajes, traigan teléfono o no. + $bsuid = $message['from_user_id'] ?? null; + + // Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso + // se busca por BSUID: si ya escribió antes mostrando su número, se le + // reconoce y se le sigue respondiendo a ese teléfono. + if (empty($phoneNumber) && $bsuid) { + $conocido = $this->db->fetch( + "SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid] + ); + // Si no se le conoce, el BSUID hace de identificador: sirve para responderle, + // aunque no permita cruzarlo con el paciente ni con el turnero. + $phoneNumber = $conocido['phone_number'] ?? $bsuid; + } + // Si falta lo crítico, saltar if (empty($phoneNumber) || empty($messageId)) { continue; @@ -153,7 +165,7 @@ class WhatsAppWebhook { // Obtener o crear usuario $user = $this->getUserByPhone($phoneNumber); - $contactName = $contactNames[$phoneNumber] ?? null; + $contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null); if (!$user) { $userId = $this->createUser($phoneNumber); $user = $this->getUserById($userId); @@ -178,6 +190,20 @@ class WhatsAppWebhook { } } + // Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra + // su teléfono. El día que lo oculte, ese registro es lo único que permitirá + // reconocerla, así que se anota en cada mensaje y no solo la primera vez. + if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) { + try { + $this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]); + $user['bsuid'] = $bsuid; + } catch (Exception $e) { + // Choca si ese BSUID ya está en otro usuario (la persona cambió de + // número). No es motivo para perder el mensaje: se sigue adelante. + error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $e->getMessage()); + } + } + // Procesar diferentes tipos de mensaje $messageText = ''; $messageType = 'text'; @@ -196,6 +222,28 @@ class WhatsAppWebhook { 'reaction_emoji' => $emoji ]; + } elseif (($message['type'] ?? '') === 'contacts') { + // La persona compartió su contacto, sea por el botón que se le pidió + // o a mano. Es la única forma de obtener el teléfono de quien lo oculta. + $messageType = 'contacts'; + $messageText = json_encode($message['contacts'] ?? []); + + $telefonoCompartido = null; + foreach ($message['contacts'] ?? [] as $c) { + foreach ($c['phones'] ?? [] as $t) { + // wa_id ya viene normalizado; `phone` puede traer espacios y signos + $candidato = $t['wa_id'] ?? ($t['phone'] ?? null); + if ($candidato) { + $telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato); + break 2; + } + } + } + + if ($telefonoCompartido && !empty($user['id'])) { + $this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid); + } + } elseif (isset($message['interactive'])) { // Interactive replies (list or button) - normalize to text so bot can process $messageType = 'text'; @@ -455,6 +503,59 @@ class WhatsAppWebhook { } } + /** + * Vincula el teléfono que la persona acaba de compartir con el usuario que + * hasta ahora solo se conocía por su BSUID. + * + * Puede haber dos registros de la misma persona: el viejo, de cuando escribía + * mostrando el número, y el nuevo creado con el BSUID de identificador. No se + * fusionan aquí (implicaría mover conversaciones, estados y aceptación de + * términos, y una fusión mal hecha mezcla historias clínicas de dos personas): + * se deja el registro con el teléfono como el bueno y se marca el otro, para + * que alguien lo revise. + * + * @return bool si el teléfono quedó vinculado + */ + private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) { + // Ya lo teníamos: nada que hacer + if (($user['phone_number'] ?? null) === $telefono) { + return true; + } + + $existente = $this->db->fetch( + "SELECT id FROM users WHERE phone_number = :t AND id <> :id", + ['t' => $telefono, 'id' => $user['id']] + ); + + try { + if ($existente) { + // El registro bueno es el que tiene el teléfono. Se le pasa el BSUID + // para que a partir de ahora se le reconozca por ahí. + if ($bsuid) { + $this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]); + $this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]); + } + error_log(sprintf( + '[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' . + 'El usuario %d queda duplicado y hay que revisarlo a mano.', + $bsuid, $telefono, $existente['id'], $user['id'] + )); + $user = $this->getUserById($existente['id']) ?: $user; + return true; + } + + // No había otro registro: el placeholder pasa a tener el teléfono real + $this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]); + $user['phone_number'] = $telefono; + error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono)); + return true; + + } catch (Exception $e) { + error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage()); + return false; + } + } + private function getUserByPhone($phoneNumber) { return $this->db->fetch( "SELECT * FROM users WHERE phone_number = :phone", diff --git a/migrations/20260811_bsuid_mapa_identidad.sql b/migrations/20260811_bsuid_mapa_identidad.sql new file mode 100644 index 0000000..e6baf11 --- /dev/null +++ b/migrations/20260811_bsuid_mapa_identidad.sql @@ -0,0 +1,21 @@ +-- 20260811_bsuid_mapa_identidad.sql +-- +-- Segunda parte del cambio de identidad de WhatsApp (ver 20260811_bsuid_identidad_whatsapp.sql). +-- +-- Meta manda el BSUID en TODOS los webhooks de mensaje, también en los que aún +-- traen teléfono. Eso permite guardar la equivalencia BSUID↔teléfono mientras +-- la persona todavía muestra su número, de modo que el día que lo oculte +-- siga siendo reconocible: ya sabemos quién es. +-- +-- El histórico de webhook_logs tiene 6.423 equivalencias que se cargan con +-- scripts/backfill_bsuid.php. +-- +-- Se guarda en una columna aparte y no en phone_number porque son dos cosas +-- distintas: el BSUID identifica, el teléfono además sirve para cruzar con el +-- paciente y el turnero. Mezclarlos rompería esos cruces. + +ALTER TABLE users ADD COLUMN bsuid VARCHAR(32) NULL DEFAULT NULL COMMENT 'Identificador de usuario por empresa (Meta). Presente aunque la persona oculte su teléfono.' AFTER phone_number; + +-- Único: un BSUID identifica a una sola persona dentro del portafolio. +-- Admite varios NULL, que es el caso de todos los usuarios ya existentes. +ALTER TABLE users ADD UNIQUE KEY uk_users_bsuid (bsuid); diff --git a/scripts/backfill_bsuid.php b/scripts/backfill_bsuid.php new file mode 100644 index 0000000..07bb87b --- /dev/null +++ b/scripts/backfill_bsuid.php @@ -0,0 +1,131 @@ +getConnection(); +$bufferPrevio = $conn->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY); +$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); + +$leidos = 0; + +try { + $stmt = $conn->query( + "SELECT request_body FROM webhook_logs + WHERE request_body LIKE '%user_id%' AND request_body LIKE '%\"messages\"%'" + ); + + while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) { + $leidos++; + if ($leidos % 50000 === 0) printf(" ...%d registros\n", $leidos); + $d = json_decode($fila['request_body'], true); + if (!$d) continue; + + foreach ($d['entry'] ?? [] as $entry) { + foreach ($entry['changes'] ?? [] as $cambio) { + $v = $cambio['value'] ?? []; + + // El teléfono puede venir en contacts[].wa_id o en messages[].from + foreach ($v['contacts'] ?? [] as $c) { + if (!empty($c['user_id']) && !empty($c['wa_id'])) { + $mapa[$c['user_id']] = $c['wa_id']; + } + } + foreach ($v['messages'] ?? [] as $m) { + $uid = $m['from_user_id'] ?? null; + if (!$uid) continue; + if (!empty($m['from'])) $mapa[$uid] = $m['from']; + else $sinTelefono[$uid] = true; + } + + // Cuando alguien comparte su contacto, el teléfono llega aquí + foreach ($v['messages'] ?? [] as $m) { + if (($m['type'] ?? '') !== 'contacts') continue; + $uid = $m['from_user_id'] ?? null; + foreach ($m['contacts'] ?? [] as $c) { + foreach ($c['phones'] ?? [] as $t) { + $num = $t['wa_id'] ?? ($t['phone'] ?? null); + if ($uid && $num) $mapa[$uid] = preg_replace('/[^0-9]/', '', $num); + } + } + } + } + } + } + $stmt->closeCursor(); +} finally { + // Dejar la conexión como estaba: las escrituras siguientes la necesitan con buffer + $conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio); +} + +printf("\n registros revisados: %d\n", $leidos); +printf(" equivalencias encontradas: %d\n", count($mapa)); +printf(" personas que llegaron sin teléfono: %d\n\n", count($sinTelefono)); + +// --- 2. Escribir el BSUID en el usuario que corresponde --- +$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0; + +foreach ($mapa as $bsuid => $telefono) { + $u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]); + if (!$u) { $noExisten++; continue; } + if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; } + if (!empty($u['bsuid'])) { + // El BSUID se regenera si la persona cambia de número: gana el más reciente + printf(" aviso: %s tenía %s y ahora %s\n", $telefono, $u['bsuid'], $bsuid); + $conflictos++; + } + if (!$simular) { + try { + $db->update('users', ['bsuid' => $bsuid], 'id = ?', [$u['id']]); + } catch (Exception $e) { + printf(" error en %s: %s\n", $telefono, $e->getMessage()); + continue; + } + } + $marcados++; +} + +printf("\n usuarios marcados con su BSUID: %d\n", $marcados); +printf(" ya lo tenían: %d\n", $yaEstaban); +printf(" sin usuario en la base: %d\n", $noExisten); +printf(" BSUID reemplazados (cambio de número): %d\n", $conflictos); + +// --- 3. ¿A cuántos de los perdidos rescatamos? --- +$rescatables = array_intersect_key($mapa, $sinTelefono); +printf("\n de los que llegaron sin teléfono, quedan identificados: %d\n", count($rescatables)); +foreach ($rescatables as $b => $t) { + printf(" %-24s → %s\n", $b, substr($t, 0, -4) . '****'); +} + +$pendientes = array_diff_key($sinTelefono, $mapa); +printf("\n siguen sin identificar: %d (hay que pedirles el contacto)\n", count($pendientes)); + +echo $simular ? "\nSimulación terminada, no se escribió nada.\n" : "\nListo.\n"; diff --git a/services/WhatsAppService.php b/services/WhatsAppService.php index 39b6d37..dafd150 100644 --- a/services/WhatsAppService.php +++ b/services/WhatsAppService.php @@ -238,6 +238,32 @@ class WhatsAppService /** * Enviar mensaje con botones interactivos */ + /** + * Pedirle a la persona que comparta su número de teléfono. + * + * Quien activó el nombre de usuario de WhatsApp llega sin teléfono, y sin él + * no se le puede cruzar con su historia ni con el turnero. Este botón es la + * vía que da Meta para pedírselo: la persona decide si lo comparte, y si + * acepta llega un mensaje de tipo `contacts` con el número. + * + * Solo tiene sentido dentro de la ventana de 24 horas; fuera de ella hay que + * usar una plantilla con el botón REQUEST_CONTACT_INFO. + */ + public function pedirContacto($to, $bodyText) + { + return $this->sendMessage([ + 'messaging_product' => 'whatsapp', + 'recipient_type' => 'individual', + 'to' => $this->formatPhoneNumber($to), + 'type' => 'interactive', + 'interactive' => [ + 'type' => 'request_contact_info', + 'body' => ['text' => $bodyText], + 'action' => ['name' => 'request_contact_info'], + ], + ]); + } + public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null) { $interactive = [