Compare commits
2
Commits
381c2ca04e
...
26025a6399
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26025a6399 | ||
|
|
92179af56a |
+112
-6
@@ -113,24 +113,41 @@ class WhatsAppWebhook {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name)
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name).
|
||||
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
|
||||
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
|
||||
$contactNames = [];
|
||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||
foreach ($value['contacts'] as $contact) {
|
||||
$waId = $contact['wa_id'] ?? null;
|
||||
$name = $contact['profile']['name'] ?? null;
|
||||
if ($waId && $name) {
|
||||
$contactNames[$waId] = $name;
|
||||
if (!$name) continue;
|
||||
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||
if ($clave) $contactNames[$clave] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $message) {
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
|
||||
$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;
|
||||
@@ -148,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);
|
||||
@@ -173,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';
|
||||
@@ -191,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';
|
||||
@@ -450,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,19 @@
|
||||
-- 20260811_bsuid_identidad_whatsapp.sql
|
||||
--
|
||||
-- Meta desplegó los nombres de usuario de WhatsApp: quien oculta su teléfono
|
||||
-- llega al webhook sin `from` ni `wa_id`, identificado solo por su BSUID
|
||||
-- (identificador de usuario por empresa), con la forma "CO.1761088155094242".
|
||||
--
|
||||
-- Ese identificador ocupa el lugar del teléfono en el flujo del bot, pero no
|
||||
-- cabe en varchar(20): los BSUID de Meta llegan a 23 caracteres, así que se
|
||||
-- truncarían en silencio y la persona quedaría imposible de responder.
|
||||
--
|
||||
-- Se amplían a varchar(32) las cuatro tablas por donde circula.
|
||||
-- Las vistas (scheduled_messages_view, v_paciente_resumen) heredan el tipo.
|
||||
-- lab_pacientes.telefono se deja igual: ahí va el teléfono real del paciente,
|
||||
-- y de estas personas justamente no lo tenemos.
|
||||
|
||||
ALTER TABLE users MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE user_states MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE terms_acceptance MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE file_requests MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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 = [
|
||||
@@ -767,6 +793,14 @@ class WhatsAppService
|
||||
unset($payload['__app_meta']);
|
||||
}
|
||||
|
||||
// A quien oculta su teléfono se le responde por BSUID, y Meta exige que vaya
|
||||
// en `recipient` en lugar de `to`. Se corrige aquí, que es por donde pasan
|
||||
// todos los envíos, en vez de en cada método que arma un payload.
|
||||
if (isset($payload['to']) && self::esBsuid($payload['to'])) {
|
||||
$payload['recipient'] = $payload['to'];
|
||||
unset($payload['to']);
|
||||
}
|
||||
|
||||
// Debug log (payload sent to WhatsApp)
|
||||
error_log("WhatsApp API URL: " . $url);
|
||||
error_log("WhatsApp Token length: " . strlen($this->token));
|
||||
@@ -881,11 +915,26 @@ class WhatsAppService
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
|
||||
* Formato de Meta: código de país, punto y dígitos. Ej.: "CO.1761088155094242".
|
||||
*/
|
||||
public static function esBsuid($destinatario)
|
||||
{
|
||||
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $destinatario);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear número de teléfono
|
||||
*/
|
||||
private function formatPhoneNumber($phone)
|
||||
{
|
||||
// Un BSUID no es un teléfono: se devuelve intacto, porque quitarle
|
||||
// el punto y las letras lo convertiría en un destinatario inexistente.
|
||||
if (self::esBsuid($phone)) {
|
||||
return $phone;
|
||||
}
|
||||
|
||||
// Remover caracteres especiales
|
||||
$phone = preg_replace('/[^0-9]/', '', $phone);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user