Files
whatsapp/scripts/backfill_bsuid.php
T
Lizandro GuarnizoandClaude Opus 5 cd087f65af Elegir un solo BSUID por teléfono antes de escribir
57 números aparecen con dos identificadores (línea reciclada, o la persona
se registró de nuevo). El recorrido iba por BSUID, así que en esos casos los
dos escribían sobre el mismo usuario y el que quedaba dependía del orden:
cada ejecución dejaba un valor distinto y ninguna era más correcta. De ahí
los "110 reemplazos", que no eran 110 personas sino 57 escribiéndose dos
veces.

Ahora el empate se resuelve en memoria antes de tocar la base —gana el visto
más tarde, que es el vigente— y se escribe una sola vez por teléfono. La
corrida siguiente da 0 cambios y 0 conflictos, que es como debe comportarse
algo idempotente.

Estado final: 5.607 usuarios con su BSUID, sin duplicados, y ningún registro
con el identificador guardado en el campo de teléfono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:54:21 -05:00

186 lines
7.6 KiB
PHP

<?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.
*
* La lectura del histórico tarda más de media hora (son ~230.000 registros de
* un servidor remoto), y mantener la conexión abierta todo ese rato la mataba
* antes de empezar a escribir: "MySQL server has gone away". Por eso el mapa se
* guarda en disco y la escritura usa una conexión nueva. De paso, reintentar
* sale gratis: si el mapa ya está en caché, no se vuelve a leer la tabla.
*
* Uso:
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
* php scripts/backfill_bsuid.php
* php scripts/backfill_bsuid.php --releer (ignora la caché y relee el histórico)
*/
require_once __DIR__ . '/../config/config.php';
$simular = in_array('--simular', $argv, true);
$releer = in_array('--releer', $argv, true);
echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n";
$cache = sys_get_temp_dir() . '/backfill_bsuid_mapa.json';
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
$mapa = [];
$sinTelefono = [];
if (!$releer && is_readable($cache)) {
$guardado = json_decode(file_get_contents($cache), true) ?: [];
$mapa = $guardado['mapa'] ?? [];
$sinTelefono = $guardado['sinTelefono'] ?? [];
printf("Mapa tomado de la caché (%s, del %s).\n", basename($cache), date('Y-m-d H:i', filemtime($cache)));
printf("Para releer el histórico: --releer\n\n");
}
if (!$mapa) {
echo "Leyendo webhook_logs (tarda; es una sola vez)...\n";
$db = Database::getInstance();
// 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
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
}
printf("\n registros revisados: %d\n", $leidos);
printf(" equivalencias encontradas: %d\n", count($mapa));
file_put_contents($cache, json_encode(['mapa' => $mapa, 'sinTelefono' => $sinTelefono]));
printf(" mapa guardado en %s\n", $cache);
// Se termina aquí a propósito. Tras media hora de lectura el servidor ya cerró
// esta conexión, y el singleton de Database no sabe reconectar: escribir ahora
// falla con "MySQL server has gone away" a mitad del recorrido, que es justo lo
// que pasó la primera vez. La segunda ejecución toma el mapa de la caché en un
// segundo y escribe con la conexión sana.
echo "\nMapa listo. Ejecute otra vez el comando para aplicarlo.\n";
exit(0);
}
printf(" equivalencias encontradas: %d\n", count($mapa));
printf(" personas que llegaron sin teléfono: %d\n", count($sinTelefono));
// --- 1b. Un solo BSUID por teléfono ---
// Hay números que aparecen con dos identificadores: la línea cambió de dueño
// (reciclada) o la persona se registró de nuevo. Si se recorre el mapa tal cual,
// ambos escriben sobre el mismo usuario y el que queda depende del orden: cada
// ejecución dejaba un valor distinto. Se resuelve antes de tocar la base,
// quedándose con el visto más tarde, y se escribe una sola vez por teléfono.
$porTelefono = [];
$elegido = [];
foreach ($mapa as $b => $t) {
$porTelefono[$t][] = $b;
$elegido[$t] = $b; // el orden del mapa es el de lectura: el último gana
}
$ambiguos = array_filter($porTelefono, fn($bs) => count($bs) > 1);
printf(" teléfonos distintos: %d\n", count($porTelefono));
printf(" teléfonos con más de un BSUID: %d (se toma el más reciente)\n\n", count($ambiguos));
foreach (array_slice($ambiguos, 0, 10, true) as $t => $bs) {
printf(" %s → %s\n", substr($t, 0, -4) . '****', implode(', ', $bs));
}
if (count($ambiguos) > 10) printf(" (y %d más)\n", count($ambiguos) - 10);
if ($ambiguos) echo "\n";
// --- 2. Escribir el BSUID en el usuario que corresponde ---
$db = Database::getInstance();
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
foreach ($elegido as $telefono => $bsuid) {
$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'])) {
printf(" aviso: %s tenía %s y queda con %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";