Saves a raw entry to webhook_logs immediately on any POST to the webhook, before HMAC check or JSON parsing. Shows HMAC status (ok/invalida/sin-firma). Live feed now shows all traffic including rejected and malformed payloads. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
590 lines
24 KiB
PHP
590 lines
24 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Controlador del Webhook de WhatsApp Business API (Meta Cloud API v18+)
|
|
*
|
|
* GET /admin/v1/wp-webhook → Verificación del webhook por Meta
|
|
* POST /admin/v1/wp-webhook → Recepción de eventos (mensajes, estados, etc.)
|
|
*/
|
|
class WpWebhook
|
|
{
|
|
/** Payload crudo del webhook actual, compartido entre métodos. */
|
|
private static string $currentRaw = '';
|
|
|
|
/** Empresa identificada para el webhook actual. */
|
|
private static ?array $currentCompany = null;
|
|
|
|
// ─── GET: Verificación Meta ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Meta envía:
|
|
* ?hub.mode=subscribe
|
|
* &hub.verify_token=TU_TOKEN
|
|
* &hub.challenge=CADENA_ALEATORIA
|
|
*
|
|
* Responde con hub.challenge si el token coincide.
|
|
*/
|
|
public static function verify(): void
|
|
{
|
|
self::debugLog('GET', $_SERVER['QUERY_STRING'] ?? '');
|
|
|
|
$mode = $_GET['hub_mode'] ?? $_GET['hub.mode'] ?? '';
|
|
$verifyToken = $_GET['hub_verify_token'] ?? $_GET['hub.verify_token'] ?? '';
|
|
$challenge = $_GET['hub_challenge'] ?? $_GET['hub.challenge'] ?? '';
|
|
|
|
$expectedToken = env('WHATSAPP_VERIFY_TOKEN', '');
|
|
|
|
if ($mode === 'subscribe' && hash_equals($expectedToken, $verifyToken)) {
|
|
http_response_code(200);
|
|
header('Content-Type: text/plain');
|
|
echo $challenge;
|
|
self::log('INFO', 'Webhook verificado por Meta.');
|
|
exit;
|
|
}
|
|
|
|
self::log('WARN', "Verificación fallida. mode=$mode token=$verifyToken");
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode(['error' => '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);
|
|
|
|
// Procesar cola outbound inmediatamente para respuestas automáticas
|
|
try {
|
|
OutboundWorker::processQueue();
|
|
} catch (\Throwable $e) {
|
|
self::log('WARN', 'OutboundWorker falló: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Resolución principal: busca el número remitente en company_phones
|
|
private static function resolveCompanyByNumber(string $from): bool
|
|
{
|
|
if ($from === '') {
|
|
self::$currentCompany = null;
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$stmt = db()->prepare("
|
|
SELECT cp.company_id, cp.permission_type
|
|
FROM company_phones cp
|
|
WHERE cp.wa_number = ? AND cp.is_active = 1
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([$from]);
|
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
} catch (\PDOException $e) {
|
|
self::log('ERROR', 'resolveCompanyByNumber DB: ' . $e->getMessage());
|
|
self::$currentCompany = null;
|
|
return false;
|
|
}
|
|
|
|
if (!$row) {
|
|
self::$currentCompany = null;
|
|
self::log('WARN', "Número {$from} no registrado en ninguna empresa");
|
|
return false;
|
|
}
|
|
|
|
$company = CompanyRepository::findById((int)$row['company_id']);
|
|
if ($company === null) {
|
|
self::$currentCompany = null;
|
|
return false;
|
|
}
|
|
|
|
// Adjuntar tipo de permiso al contexto de empresa
|
|
$company['_permission_type'] = (int)$row['permission_type'];
|
|
self::$currentCompany = $company;
|
|
self::log('INFO', "Número {$from} → empresa: {$company['name']} (permiso tipo {$row['permission_type']})");
|
|
return true;
|
|
}
|
|
|
|
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 por número remitente
|
|
$allowed = self::resolveCompanyByNumber($from);
|
|
|
|
if (!$allowed) {
|
|
self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]');
|
|
self::sendRejectionMessage($from, $phoneNumberId);
|
|
continue;
|
|
}
|
|
|
|
$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);
|
|
self::saveConversation($ctx, $body);
|
|
self::forwardToCompany($ctx, $body);
|
|
|
|
if (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);
|
|
self::saveConversation($ctx, $preview, $mediaId);
|
|
self::forwardToCompany($ctx, $preview, $mediaId);
|
|
}
|
|
|
|
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);
|
|
self::saveConversation($ctx, $preview);
|
|
self::forwardToCompany($ctx, $preview);
|
|
|
|
if (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);
|
|
self::saveConversation($ctx, $preview);
|
|
self::forwardToCompany($ctx, $preview);
|
|
|
|
if (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): void
|
|
{
|
|
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);
|
|
}
|
|
} catch (\PDOException $e) {
|
|
self::log('ERROR', 'DB saveConversation: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|