'Verificación fallida'], JSON_UNESCAPED_UNICODE); exit; } // ─── POST: Recepción de eventos ─────────────────────────────────────────── public static function receive(): void { $rawBody = file_get_contents('php://input'); $headers = getallheaders(); self::debugLog('POST', json_encode(['headers' => $headers, 'body' => $rawBody], JSON_UNESCAPED_UNICODE)); // Guardar raw body para los handlers self::$currentRaw = $rawBody ?: ''; // Guardar SIEMPRE una entrada raw en webhook_logs antes de cualquier validación self::saveRawIncoming($rawBody ?: '', $headers); if ($rawBody === false || $rawBody === '') { self::respond(400, ['error' => 'Body vacío']); } self::verifySignature($rawBody); $payload = json_decode($rawBody, true); if (!is_array($payload)) { self::respond(400, ['error' => 'JSON inválido']); } self::processEvent($payload, $rawBody); self::respond(200, ['status' => 'received']); } private static function saveRawIncoming(string $rawBody, array $headers): void { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $preview = mb_substr($rawBody, 0, 300); $sigOk = 'pendiente'; $appSecret = env('WHATSAPP_APP_SECRET', ''); $hmacEnabled = env('verify_hmac_signature', '1'); if ($hmacEnabled !== '0' && $appSecret !== '') { $sigHeader = $headers['X-Hub-Signature-256'] ?? $headers['x-hub-signature-256'] ?? ''; if (strpos($sigHeader, 'sha256=') === 0) { $received = substr($sigHeader, 7); $expected = hash_hmac('sha256', $rawBody, $appSecret); $sigOk = hash_equals($expected, $received) ? 'ok' : 'invalida'; } else { $sigOk = 'sin-firma'; } } else { $sigOk = 'desactivada'; } try { $stmt = db()->prepare(" INSERT INTO webhook_logs (company_id, event_field, from_number, contact_name, message_type, message_preview, raw_payload) VALUES (NULL, 'raw', ?, ?, 'raw', ?, ?) "); $stmt->execute([$ip, "HMAC:{$sigOk}", mb_substr($preview, 0, 500), $rawBody]); } catch (\PDOException $e) { self::log('ERROR', 'saveRawIncoming DB: ' . $e->getMessage()); } } // ─── Procesamiento de eventos ───────────────────────────────────────────── private static function processEvent(array $payload, string $raw): void { $object = $payload['object'] ?? ''; if ($object !== 'whatsapp_business_account') { self::log('WARN', "Objeto desconocido: $object"); return; } self::resolveCompany($payload); $entries = $payload['entry'] ?? []; foreach ($entries as $entry) { $changes = $entry['changes'] ?? []; foreach ($changes as $change) { $field = $change['field'] ?? ''; $value = $change['value'] ?? []; if ($field === 'messages' || $field === 'conversations') { self::handleMessages($value); } elseif ($field === 'statuses') { self::handleStatuses($value); } else { self::log('INFO', "Campo no manejado: $field"); } } } // Persistir evento crudo para auditoría self::saveRawEvent($raw); // Note: BotRouter already sends synchronously in enqueueResponse(). // Running processQueue() here would re-send ALL previously-failed messages // on every incoming webhook, causing duplicate/repeated responses. } private static function resolveCompany(array $payload): void { // Fallback: resolver por phone_number_id (usado para statuses y auditoría) $phoneNumberId = ''; foreach ($payload['entry'] ?? [] as $entry) { foreach ($entry['changes'] ?? [] as $change) { $metadata = $change['value']['metadata'] ?? []; if (!empty($metadata['phone_number_id'])) { $phoneNumberId = $metadata['phone_number_id']; break 2; } } } if ($phoneNumberId !== '') { self::$currentCompany = CompanyRepository::findByPhoneNumberId($phoneNumberId); } } // Devuelve todas las empresas a las que pertenece el número private static function findCompaniesByNumber(string $from): array { if ($from === '') return []; try { $stmt = db()->prepare(" SELECT cp.company_id, cp.permission_type FROM company_phones cp WHERE cp.wa_number = ? AND cp.is_active = 1 ORDER BY cp.company_id ASC "); $stmt->execute([$from]); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } catch (\PDOException $e) { self::log('ERROR', 'findCompaniesByNumber DB: ' . $e->getMessage()); return []; } } // Pre-menú multi-empresa: retorna true si la empresa quedó resuelta, false si hay que esperar al usuario private static function handleMultiCompany(string $from, string $name, array $companies, string $rawText, string $phoneNumberId, string $msgType = 'text'): bool { // 1. El usuario tocó un botón de selección de empresa if (str_starts_with($rawText, '__co_')) { $selectedId = (int)substr($rawText, 5); $company = CompanyRepository::findById($selectedId); if (!$company) { self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); return false; } try { db()->prepare(" INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR)) ON DUPLICATE KEY UPDATE selected_id = VALUES(selected_id), expires_at = VALUES(expires_at) ")->execute([$from, json_encode($companies), $selectedId]); } catch (\PDOException $e) { self::log('ERROR', 'handleMultiCompany save session: ' . $e->getMessage()); } $company['_permission_type'] = self::getPermissionType($companies, $selectedId); self::$currentCompany = $company; $displayName = $company['display_name'] ?: $company['name']; // Mensaje 1: confirmación WhatsAppSender::sendText($from, "✅ Conectado con *{$displayName}*.", $phoneNumberId); // Mensaje 2: greeting del bot — reset contexto y disparar con mensaje vacío $botCtx = ConversationContext::getOrCreate((int)$selectedId, $from, 'normal'); ConversationContext::reset((int)$botCtx['id']); $synthCtx = [ 'from' => $from, 'name' => $name, 'message_id' => 'mc_' . uniqid(), 'type' => 'text', 'timestamp' => time(), 'phone_number_id' => $phoneNumberId, 'display_phone' => '', 'permission_type' => self::getPermissionType($companies, $selectedId), ]; BotRouter::route($company, $synthCtx, '', 'text'); self::log('INFO', "Multi-empresa: {$from} seleccionó empresa #{$selectedId} ({$displayName})"); return false; } // 2. Keyword de reinicio → borrar sesión y mostrar pre-menú // Solo aplica para texto libre; las respuestas interactivas no son keywords if ($msgType === 'text' && self::isResetKeyword($rawText)) { try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {} self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); return false; } // 3. Sesión activa → usar empresa guardada try { $stmt = db()->prepare("SELECT selected_id FROM multi_company_sessions WHERE wa_number = ? AND selected_id IS NOT NULL AND expires_at > NOW() LIMIT 1"); $stmt->execute([$from]); $session = $stmt->fetch(\PDO::FETCH_ASSOC); } catch (\PDOException $e) { self::log('ERROR', 'handleMultiCompany read session: ' . $e->getMessage()); $session = null; } if ($session) { $selectedId = (int)$session['selected_id']; try { db()->prepare("UPDATE multi_company_sessions SET expires_at = DATE_ADD(NOW(), INTERVAL 8 HOUR) WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {} $company = CompanyRepository::findById($selectedId); if (!$company) { try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {} self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); return false; } $company['_permission_type'] = self::getPermissionType($companies, $selectedId); self::$currentCompany = $company; return true; } // 4. Sin sesión → mostrar pre-menú try { db()->prepare(" INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at) VALUES (?, ?, NULL, DATE_ADD(NOW(), INTERVAL 10 MINUTE)) ON DUPLICATE KEY UPDATE companies_json = VALUES(companies_json), selected_id = NULL, expires_at = VALUES(expires_at) ")->execute([$from, json_encode($companies)]); } catch (\PDOException $e) { self::log('ERROR', 'handleMultiCompany insert pending: ' . $e->getMessage()); } self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies); return false; } private static function sendCompanyPreMenu(string $to, string $phoneNumberId, string $name, array $companies): void { $items = []; foreach ($companies as $cp) { $co = CompanyRepository::findById((int)$cp['company_id']); if ($co) $items[] = ['id' => (int)$cp['company_id'], 'name' => $co['display_name'] ?: $co['name']]; } if (empty($items)) return; $greeting = $name ? "Hola *{$name}* 👋\n" : "Hola 👋\n"; $body = $greeting . "¿Con cuál empresa deseas comunicarte?"; if (count($items) <= 3) { $buttons = []; foreach ($items as $item) { $buttons[] = ['type' => 'reply', 'reply' => ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 20)]]; } $interactive = ['type' => 'button', 'body' => ['text' => $body], 'action' => ['buttons' => $buttons]]; } else { $rows = []; foreach ($items as $item) { $rows[] = ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 24)]; } $interactive = ['type' => 'list', 'body' => ['text' => $body], 'action' => ['button' => 'Ver empresas', 'sections' => [['title' => 'Empresas', 'rows' => $rows]]]]; } WhatsAppSender::sendInteractive($to, $interactive, $phoneNumberId); self::log('INFO', "Pre-menú de empresa enviado a {$to} (" . count($items) . " opciones)"); } /** * Solo palabras que signifiquen "cambiar de empresa". Antes incluía menu, * salir, atras, volver, inicio y regresar — el vocabulario de navegación del * bot— así que a un usuario multi-empresa se le comían todos esos comandos * acá arriba y nunca llegaban a NormalBot. */ private static function isResetKeyword(string $text): bool { $n = mb_strtolower(trim($text)); $n = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $n); return in_array($n, ['cambiar empresa', 'cambiar de empresa', 'empresas', 'reiniciar', 'reset'], true); } private static function getPermissionType(array $companies, int $companyId): int { foreach ($companies as $cp) { if ((int)$cp['company_id'] === $companyId) return (int)$cp['permission_type']; } return 3; } private static function sendRejectionMessage(string $to, string $phoneNumberId): void { $token = env('WHATSAPP_ACCESS_TOKEN', ''); if ($token === '' || $to === '' || $phoneNumberId === '') return; $body = json_encode([ 'messaging_product' => 'whatsapp', 'to' => $to, 'type' => 'text', 'text' => ['body' => 'Comuníquese con el administrador, su número no se encuentra habilitado.'], ]); $ch = curl_init("https://graph.facebook.com/v17.0/{$phoneNumberId}/messages"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); self::log('INFO', "Rechazo enviado a {$to} HTTP {$code}"); } // ─── Reenvío a empresa ──────────────────────────────────────────────────── private static function forwardToCompany(array $context, string $content, ?string $mediaId = null): void { if (self::$currentCompany === null) { return; } $messageData = array_merge($context, [ 'content' => $content, 'media_id' => $mediaId, 'raw_payload' => self::$currentRaw, ]); $result = CompanyApiClient::forwardMessage(self::$currentCompany, $messageData); if ($result['success']) { self::log('INFO', "Mensaje reenviado a empresa: " . (self::$currentCompany['name'] ?? '?')); } else { self::log('ERROR', "Fallo reenvío a empresa: " . ($result['error'] ?? json_encode($result))); } } // ─── Mensajes entrantes ─────────────────────────────────────────────────── private static function handleMessages(array $value): void { $messages = $value['messages'] ?? []; $contacts = $value['contacts'] ?? []; $metadata = $value['metadata'] ?? []; $phoneNumberId = $metadata['phone_number_id'] ?? ''; $displayPhone = $metadata['display_phone_number'] ?? ''; foreach ($messages as $msg) { $from = $msg['from'] ?? ''; $msgId = $msg['id'] ?? ''; $type = $msg['type'] ?? 'unknown'; $ts = $msg['timestamp'] ?? time(); $name = ''; foreach ($contacts as $c) { if (($c['wa_id'] ?? '') === $from) { $name = $c['profile']['name'] ?? ''; break; } } // Resolver empresa(s) por número remitente $matchedCompanies = self::findCompaniesByNumber($from); if (empty($matchedCompanies)) { self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]'); self::sendRejectionMessage($from, $phoneNumberId); continue; } if (count($matchedCompanies) === 1) { $co = CompanyRepository::findById((int)$matchedCompanies[0]['company_id']); if (!$co) continue; $co['_permission_type'] = (int)$matchedCompanies[0]['permission_type']; self::$currentCompany = $co; self::log('INFO', "Número {$from} → empresa: {$co['name']} (permiso {$matchedCompanies[0]['permission_type']})"); } else { $rawText = match ($type) { 'text' => $msg['text']['body'] ?? '', 'interactive' => $msg['interactive']['button_reply']['id'] ?? $msg['interactive']['list_reply']['id'] ?? '', 'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '', default => '', }; if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId, $type)) { continue; } self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?')); } $context = [ 'from' => $from, 'name' => $name, 'message_id' => $msgId, 'type' => $type, 'timestamp' => $ts, 'phone_number_id' => $phoneNumberId, 'display_phone' => $displayPhone, 'permission_type' => self::$currentCompany['_permission_type'] ?? 3, ]; switch ($type) { case 'text': self::handleText($msg, $context); break; case 'image': self::handleMedia($msg, $context, 'image'); break; case 'audio': self::handleMedia($msg, $context, 'audio'); break; case 'video': self::handleMedia($msg, $context, 'video'); break; case 'document': self::handleMedia($msg, $context, 'document'); break; case 'sticker': self::handleMedia($msg, $context, 'sticker'); break; case 'location': self::handleLocation($msg, $context); break; case 'interactive': self::handleInteractive($msg, $context); break; case 'button': self::handleButton($msg, $context); break; case 'reaction': self::handleReaction($msg, $context); break; default: self::log('INFO', "Tipo no manejado: $type | from=$from"); break; } } if (!empty($value['statuses'])) { self::handleStatuses($value); } } private static function handleText(array $msg, array $ctx): void { $body = $msg['text']['body'] ?? ''; self::log('MSG', "[TEXT] {$ctx['from']} ({$ctx['name']}): $body"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'text', $body); $isNew = self::saveConversation($ctx, $body); self::forwardToCompany($ctx, $body); if ($isNew && self::$currentCompany !== null) { BotRouter::route(self::$currentCompany, $ctx, $body, 'text'); } } private static function handleMedia(array $msg, array $ctx, string $type): void { $data = $msg[$type] ?? []; $mediaId = $data['id'] ?? ''; $mime = $data['mime_type'] ?? ''; $caption = $data['caption'] ?? ''; $preview = $caption ?: "[$type id:$mediaId]"; self::log('MSG', "[" . strtoupper($type) . "] {$ctx['from']} | id=$mediaId mime=$mime caption=$caption"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], $type, $preview); $isNew = self::saveConversation($ctx, $preview, $mediaId); self::forwardToCompany($ctx, $preview, $mediaId); if ($isNew && self::$currentCompany !== null) { // media_id in context so BotRouter/MediaTranscriber can download the file $ctx['media_id'] = $mediaId; BotRouter::route(self::$currentCompany, $ctx, $caption, $type); } } private static function handleLocation(array $msg, array $ctx): void { $lat = $msg['location']['latitude'] ?? ''; $lng = $msg['location']['longitude'] ?? ''; $name = $msg['location']['name'] ?? ''; $preview = "lat:$lat lng:$lng" . ($name ? " ($name)" : ''); self::log('MSG', "[LOCATION] {$ctx['from']} | lat=$lat lng=$lng name=$name"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'location', $preview); self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); } private static function handleInteractive(array $msg, array $ctx): void { $iType = $msg['interactive']['type'] ?? ''; if ($iType === 'button_reply') { $reply = $msg['interactive']['button_reply'] ?? []; } elseif ($iType === 'list_reply') { $reply = $msg['interactive']['list_reply'] ?? []; } else { $reply = []; } $preview = $iType . ': ' . json_encode($reply); $replyId = $reply['id'] ?? ''; self::log('MSG', "[INTERACTIVE/$iType] {$ctx['from']} | reply=" . json_encode($reply)); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'interactive', $preview); $isNew = self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); if ($isNew && self::$currentCompany !== null && $replyId !== '') { BotRouter::route(self::$currentCompany, $ctx, $replyId, 'interactive'); } } private static function handleButton(array $msg, array $ctx): void { $text = $msg['button']['text'] ?? ''; $payload = $msg['button']['payload'] ?? ''; $preview = "$text | $payload"; $replyId = $payload ?: $text; self::log('MSG', "[BUTTON] {$ctx['from']} | text=$text payload=$payload"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'button', $preview); $isNew = self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); if ($isNew && self::$currentCompany !== null && $replyId !== '') { BotRouter::route(self::$currentCompany, $ctx, $replyId, 'button'); } } private static function handleReaction(array $msg, array $ctx): void { $emoji = $msg['reaction']['emoji'] ?? ''; $reactTo = $msg['reaction']['message_id'] ?? ''; $preview = "emoji:$emoji replyTo:$reactTo"; self::log('MSG', "[REACTION] {$ctx['from']} ({$ctx['name']}) | emoji=$emoji msg=$reactTo"); self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'reaction', $preview); self::saveConversation($ctx, $preview); self::forwardToCompany($ctx, $preview); } // ─── Estados de mensajes enviados ──────────────────────────────────────── private static function handleStatuses(array $value): void { $statuses = $value['statuses'] ?? []; foreach ($statuses as $s) { $id = $s['id'] ?? ''; $status = $s['status'] ?? ''; // sent | delivered | read | failed $recipient = $s['recipient_id'] ?? ''; $ts = $s['timestamp'] ?? ''; if ($status === 'failed') { $errors = $s['errors'] ?? []; self::log('ERROR', "[STATUS:failed] msg=$id recipient=$recipient errors=" . json_encode($errors)); } else { self::log('INFO', "[STATUS:$status] msg=$id recipient=$recipient ts=$ts"); } self::saveWebhookLog('statuses', $recipient, '', $status, "msg_id:$id"); if (self::$currentCompany !== null) { $statusData = [ 'message_id' => $id, 'status' => $status, 'recipient' => $recipient, 'timestamp' => $ts, 'errors' => $s['errors'] ?? null, ]; CompanyApiClient::forwardStatus(self::$currentCompany, $statusData); } } } // ─── Firma HMAC-SHA256 de Meta ──────────────────────────────────────────── private static function verifySignature(string $rawBody): void { $appSecret = env('WHATSAPP_APP_SECRET', ''); // Si el checkbox "Validar firma HMAC" está desactivado (0), saltar validación $enabled = env('verify_hmac_signature', '1'); if ($enabled === '0') { self::log('INFO', 'Validación HMAC desactivada por configuración.'); return; } if ($appSecret === '') { self::log('WARN', 'WHATSAPP_APP_SECRET no configurado. Saltando verificación de firma.'); return; } $sigHeader = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''; if (strpos($sigHeader, 'sha256=') !== 0) { self::respond(401, ['error' => 'Firma ausente']); } $received = substr($sigHeader, 7); $expected = hash_hmac('sha256', $rawBody, $appSecret); if (!hash_equals($expected, $received)) { self::log('WARN', 'Firma HMAC inválida. Posible payload adulterado.'); self::respond(401, ['error' => 'Firma inválida']); } } // ─── Utilidades ─────────────────────────────────────────────────────────── // ─── Guardar en base de datos ───────────────────────────────────────────── private static function saveConversation(array $ctx, string $content, ?string $mediaId = null): bool { try { $companyId = self::$currentCompany['id'] ?? null; $stmt = db()->prepare(" INSERT IGNORE INTO conversations (company_id, message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp) VALUES (?, ?, ?, ?, 'inbound', ?, ?, ?, ?) "); $stmt->execute([ $companyId, $ctx['message_id'], $ctx['from'], $ctx['name'], $ctx['type'], mb_substr($content, 0, 1000), $mediaId, $ctx['timestamp'], ]); if ($stmt->rowCount() > 0) { $convId = (int) db()->lastInsertId(); self::saveNotification($convId, $ctx['from'], $content); return true; } // Mensaje duplicado (webhook retry de Meta) — no procesar de nuevo self::log('INFO', "Mensaje duplicado ignorado: {$ctx['message_id']} from={$ctx['from']}"); return false; } catch (\PDOException $e) { self::log('ERROR', 'DB saveConversation: ' . $e->getMessage()); return true; // En caso de error DB, dejar pasar para no silenciar mensajes reales } } private static function saveNotification(int $referenceId, string $phone, string $preview): void { try { $companyId = self::$currentCompany['id'] ?? null; $stmt = db()->prepare(" INSERT INTO notifications (company_id, type, reference_id, phone_number, message) VALUES (?, 'new_message', ?, ?, ?) "); $stmt->execute([$companyId, $referenceId, $phone, mb_substr($preview, 0, 255)]); } catch (\PDOException $e) { self::log('ERROR', 'DB saveNotification: ' . $e->getMessage()); } } private static function saveWebhookLog( string $field, string $from, string $name, string $type, string $preview ): void { try { $companyId = self::$currentCompany['id'] ?? null; $stmt = db()->prepare(" INSERT INTO webhook_logs (company_id, event_field, from_number, contact_name, message_type, message_preview, raw_payload) VALUES (?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([$companyId, $field, $from, $name, $type, mb_substr($preview, 0, 500), self::$currentRaw]); } catch (\PDOException $e) { self::log('ERROR', 'DB saveWebhookLog: ' . $e->getMessage()); } } /** * Guarda el evento crudo en storage/events/ para auditoría. */ private static function saveRawEvent(string $raw): void { $dir = dirname(__DIR__, 2) . '/storage/events'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $file = $dir . '/' . date('Y-m-d') . '.log'; $line = '[' . date('Y-m-d H:i:s') . '] ' . $raw . PHP_EOL; file_put_contents($file, $line, FILE_APPEND | LOCK_EX); } private static function log(string $level, string $message): void { $dir = dirname(__DIR__, 2) . '/storage/logs'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $file = $dir . '/webhook-' . date('Y-m-d') . '.log'; $line = '[' . date('Y-m-d H:i:s') . "] [$level] $message" . PHP_EOL; file_put_contents($file, $line, FILE_APPEND | LOCK_EX); // También a stderr en desarrollo if (env('APP_ENV', 'production') === 'local') { error_log($line); } } private static function debugLog(string $method, string $data): void { $log = '[' . date('Y-m-d H:i:s') . "] {$method}\n{$data}\n" . str_repeat('-', 60) . "\n"; @file_put_contents('/tmp/wp-debug.log', $log, FILE_APPEND); } private static function respond(int $code, array $body): void { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($body, JSON_UNESCAPED_UNICODE); exit; } }