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",