61b18df8e8
- Corrige formato de fechas a español con Carbon isoFormat('D [de] MMMM')
- Webhook procesa statuses de Meta (delivered/read/failed) y actualiza pivot alumno_promocion
- Analytics: nuevas métricas de entregados/leídos, embudo de entrega, filtro por tipo
- Inspector de payload raw por log con modal y botón copiar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
225 lines
7.0 KiB
PHP
225 lines
7.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Alumno;
|
|
use App\Models\Promocion;
|
|
use App\Models\WhatsappLog;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class WhatsAppService
|
|
{
|
|
private string $token;
|
|
private string $phoneId;
|
|
private string $apiUrl;
|
|
|
|
public function __construct()
|
|
{
|
|
$token = config('services.whatsapp.token');
|
|
$phoneId = config('services.whatsapp.phone_id');
|
|
|
|
if (!$token || !$phoneId) {
|
|
throw new \RuntimeException(
|
|
'WhatsApp no configurado. Agrega WHATSAPP_TOKEN y WHATSAPP_PHONE_ID en el archivo .env'
|
|
);
|
|
}
|
|
|
|
$this->token = $token;
|
|
$this->phoneId = $phoneId;
|
|
$this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages";
|
|
}
|
|
|
|
public function enviarTemplate(string $telefono, string $templateName, array $parametros = [], ?string $imagenUrl = null): ?string
|
|
{
|
|
$components = [];
|
|
|
|
if ($imagenUrl) {
|
|
$components[] = [
|
|
'type' => 'header',
|
|
'parameters' => [
|
|
[
|
|
'type' => 'image',
|
|
'image' => ['link' => $imagenUrl],
|
|
]
|
|
],
|
|
];
|
|
}
|
|
|
|
if (!empty($parametros)) {
|
|
$components[] = [
|
|
'type' => 'body',
|
|
'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros),
|
|
];
|
|
}
|
|
|
|
$response = Http::withToken($this->token)->post($this->apiUrl, [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->normalizarTelefono($telefono),
|
|
'type' => 'template',
|
|
'template' => [
|
|
'name' => $templateName,
|
|
'language' => ['code' => 'es_MX'],
|
|
'components' => $components,
|
|
],
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json()]);
|
|
return null;
|
|
}
|
|
|
|
return $response->json('messages.0.id');
|
|
}
|
|
|
|
/*public function enviarPromocion(string $telefono, Promocion $promocion): string
|
|
{
|
|
$payload = [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->normalizarTelefono($telefono),
|
|
];
|
|
|
|
if ($promocion->imagen) {
|
|
$payload['type'] = 'image';
|
|
$payload['image'] = [
|
|
'link' => asset('storage/' . $promocion->imagen),
|
|
'caption' => "{$promocion->titulo}\n\n{$promocion->descripcion}",
|
|
];
|
|
} else {
|
|
$payload['type'] = 'text';
|
|
$payload['text'] = ['body' => "{$promocion->titulo}\n\n{$promocion->descripcion}"];
|
|
}
|
|
|
|
$response = Http::withToken($this->token)->post($this->apiUrl, $payload);
|
|
|
|
if ($response->failed()) {
|
|
Log::error('WhatsApp promocion error', [
|
|
'telefono' => $telefono,
|
|
'status' => $response->status(),
|
|
'body' => $response->body(),
|
|
]);
|
|
|
|
throw new \RuntimeException($response->body());
|
|
}
|
|
|
|
return $response->json('messages.0.id');
|
|
}*/
|
|
|
|
public function enviarPromocion(string $telefono, Promocion $promocion, string $nombre = ''): string
|
|
{
|
|
$codigo = 'PROMO-' . strtoupper(substr(md5($promocion->id . $telefono), 0, 6));
|
|
|
|
$mensajeId = $this->enviarTemplate(
|
|
$telefono,
|
|
'promocion_laravel',
|
|
[
|
|
$nombre,
|
|
$promocion->fecha_inicio->locale('es')->isoFormat('D [de] MMMM'),
|
|
$promocion->fecha_fin->locale('es')->isoFormat('D [de] MMMM'),
|
|
$codigo,
|
|
],
|
|
$promocion->imagenUrl()
|
|
);
|
|
|
|
if (!$mensajeId) {
|
|
throw new \RuntimeException('No se pudo enviar la plantilla');
|
|
}
|
|
|
|
return $mensajeId;
|
|
}
|
|
|
|
public function procesarOptOut(string $telefono): void
|
|
{
|
|
$normalizado = $this->normalizarTelefono($telefono);
|
|
|
|
Alumno::whereHas('alumnos', function ($q) use ($normalizado) {
|
|
$q->where('telefono', $normalizado)
|
|
->orWhere('telefono', ltrim($normalizado, '52'));
|
|
})->each(function (Alumno $alumno) {
|
|
$alumno->update([
|
|
'opt_out' => true,
|
|
'opt_out_at' => now(),
|
|
]);
|
|
});
|
|
|
|
WhatsappLog::create([
|
|
'telefono' => $normalizado,
|
|
'tipo' => 'opt_out',
|
|
'mensaje' => 'Usuario solicitó opt-out',
|
|
]);
|
|
}
|
|
|
|
public function descargarMedia(string $mediaId): ?string
|
|
{
|
|
$infoResponse = Http::withToken($this->token)
|
|
->get("https://graph.facebook.com/v19.0/{$mediaId}");
|
|
|
|
if ($infoResponse->failed()) {
|
|
Log::error('WhatsApp media info error', ['mediaId' => $mediaId]);
|
|
return null;
|
|
}
|
|
|
|
$mediaUrl = $infoResponse->json('url');
|
|
$mimeType = $infoResponse->json('mime_type', 'application/octet-stream');
|
|
$extension = $this->extensionFromMime($mimeType);
|
|
|
|
$fileResponse = Http::withToken($this->token)->get($mediaUrl);
|
|
|
|
if ($fileResponse->failed()) {
|
|
return null;
|
|
}
|
|
|
|
$filename = 'whatsapp_media/' . $mediaId . '.' . $extension;
|
|
Storage::disk('public')->put($filename, $fileResponse->body());
|
|
|
|
return $filename;
|
|
}
|
|
|
|
public function enviarTexto(string $telefono, string $texto): ?string
|
|
{
|
|
$response = Http::withToken($this->token)->post($this->apiUrl, [
|
|
'messaging_product' => 'whatsapp',
|
|
'to' => $this->normalizarTelefono($telefono),
|
|
'type' => 'text',
|
|
'text' => ['body' => $texto],
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
return null;
|
|
}
|
|
|
|
return $response->json('messages.0.id');
|
|
}
|
|
|
|
private function normalizarTelefono(string $telefono): string
|
|
{
|
|
$limpio = preg_replace('/\D/', '', $telefono);
|
|
|
|
// Si ya tiene el prefijo 52 (12 dígitos), quítalo para re-agregarlo limpio
|
|
if (str_starts_with($limpio, '52') && strlen($limpio) === 12) {
|
|
$limpio = substr($limpio, 2);
|
|
}
|
|
|
|
// Siempre anteponer el código de país México
|
|
return '52' . $limpio;
|
|
}
|
|
|
|
private function extensionFromMime(string $mimeType): string
|
|
{
|
|
return match (true) {
|
|
str_contains($mimeType, 'jpeg') => 'jpg',
|
|
str_contains($mimeType, 'png') => 'png',
|
|
str_contains($mimeType, 'gif') => 'gif',
|
|
str_contains($mimeType, 'webp') => 'webp',
|
|
str_contains($mimeType, 'mp4') => 'mp4',
|
|
str_contains($mimeType, 'ogg') => 'ogg',
|
|
str_contains($mimeType, 'mpeg') => 'mp3',
|
|
str_contains($mimeType, 'pdf') => 'pdf',
|
|
str_contains($mimeType, 'msword') => 'doc',
|
|
str_contains($mimeType, 'spreadsheet')=> 'xlsx',
|
|
default => 'bin',
|
|
};
|
|
}
|
|
}
|