Reconocer por BSUID a quien oculta su teléfono, y poder pedírselo

Meta no permite averiguar el teléfono a partir del BSUID: no hay endpoint de
consulta inversa, cada empresa debe llevar su propia equivalencia. Pero el
BSUID llega en TODOS los webhooks de mensaje, también en los que aún traen
teléfono, así que la equivalencia se puede ir guardando sola mientras la
persona todavía muestra su número.

- users.bsuid guarda esa equivalencia, y el webhook la anota en cada mensaje.
  Cuando alguien oculte su número, se le seguirá reconociendo y respondiendo
  a su teléfono de siempre.
- Va en columna aparte y no en phone_number porque el teléfono además cruza
  con el paciente y el turnero; mezclarlos rompería esos cruces.
- Para quien nunca escribió mostrando el número queda pedirle el contacto:
  pedirContacto() manda el botón request_contact_info, y el webhook atiende
  el mensaje `contacts` que llega si acepta.
- Al vincular puede aparecer un segundo registro de la misma persona. No se
  fusionan: una fusión mal hecha mezcla dos historias clínicas. Gana el que
  tiene el teléfono, el otro queda anotado en el log para revisarlo a mano.
- scripts/backfill_bsuid.php carga las equivalencias del histórico (6.423),
  con --simular para verlas sin escribir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-11 20:07:52 -05:00
co-authored by Claude Opus 5
parent 92179af56a
commit 26025a6399
4 changed files with 284 additions and 5 deletions
+106 -5
View File
@@ -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",
@@ -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);
+131
View File
@@ -0,0 +1,131 @@
<?php
/**
* scripts/backfill_bsuid.php
*
* Rellena users.bsuid a partir del histórico de webhook_logs.
*
* Meta manda el BSUID en todos los webhooks de mensaje, así que en el histórico
* están las equivalencias BSUID↔teléfono de la gente que escribió cuando aún
* mostraba su número. Cargarlas significa que, el día que oculten el teléfono,
* el bot siga sabiendo quiénes son en vez de tratarlos como desconocidos.
*
* Es idempotente: se puede correr las veces que haga falta.
*
* Uso:
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
* php scripts/backfill_bsuid.php
*/
require_once __DIR__ . '/../config/config.php';
$simular = in_array('--simular', $argv, true);
echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n";
$db = Database::getInstance();
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
echo "Leyendo webhook_logs...\n";
$mapa = [];
$sinTelefono = [];
// Una sola pasada sin buffer: son más de 400.000 registros, así que ni caben
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
// volvía a recorrerla entera y tardaba una eternidad).
$conn = $db->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";
+26
View File
@@ -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 = [