Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
WhatsAppService
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 verifyWebhook
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 verifySignature
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 parseIncomingMessages
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
72
 sendMessage
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 markAsRead
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Service;
4
5use App\Models\SocialSetting;
6use Illuminate\Support\Facades\Http;
7use Illuminate\Support\Facades\Log;
8
9class WhatsAppService
10{
11    protected string $token;
12
13    protected string $phoneNumberId;
14
15    protected string $verifyToken;
16
17    protected string $appSecret;
18
19    public function __construct()
20    {
21        $this->token = (string) SocialSetting::getSetting('wa_cloud_api_token', '');
22        $this->phoneNumberId = (string) SocialSetting::getSetting('wa_phone_number_id', '');
23        $this->verifyToken = (string) SocialSetting::getSetting('wa_verify_token', '');
24        // App secret used to sign webhook payloads (same Meta App Dashboard as Messenger).
25        // Store it as a social_settings row with key 'wa_app_secret'.
26        $this->appSecret = (string) SocialSetting::getSetting('wa_app_secret', '');
27    }
28
29    /**
30     * Verify WhatsApp webhook challenge
31     */
32    public function verifyWebhook(array $params): ?string
33    {
34        if (
35            isset($params['hub.mode'], $params['hub.verify_token'], $params['hub.challenge']) &&
36            $params['hub.mode'] === 'subscribe' &&
37            $params['hub.verify_token'] === $this->verifyToken
38        ) {
39            return $params['hub.challenge'];
40        }
41
42        return null;
43    }
44
45    /**
46     * SECURITY FIX: verify Meta's X-Hub-Signature-256 header on incoming WhatsApp
47     * webhook POSTs, same reasoning as FacebookMessengerService::verifySignature().
48     * If no app secret is configured, logs a warning and allows the request through.
49     */
50    public function verifySignature(string $rawPayload, ?string $signatureHeader): bool
51    {
52        if (empty($this->appSecret)) {
53            Log::warning('WhatsApp: wa_app_secret not configured, skipping webhook signature verification.');
54
55            return true;
56        }
57
58        if (empty($signatureHeader) || ! str_starts_with($signatureHeader, 'sha256=')) {
59            return false;
60        }
61
62        $expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $this->appSecret);
63
64        return hash_equals($expected, $signatureHeader);
65    }
66
67    /**
68     * Parse incoming WhatsApp messages
69     * Returns array of ['sender_id', 'message', 'sender_name', 'message_id']
70     */
71    public function parseIncomingMessages(array $payload): array
72    {
73        $messages = [];
74
75        $entries = $payload['entry'] ?? [];
76
77        foreach ($entries as $entry) {
78            $changes = $entry['changes'] ?? [];
79
80            foreach ($changes as $change) {
81                $value = $change['value'] ?? [];
82
83                // Skip status updates (delivered, read etc.)
84                if (! isset($value['messages'])) {
85                    continue;
86                }
87
88                foreach ($value['messages'] as $msg) {
89                    // Only handle text messages
90                    if ($msg['type'] !== 'text') {
91                        continue;
92                    }
93
94                    $senderId = $msg['from'];
95                    $messageId = $msg['id'];
96                    $text = $msg['text']['body'] ?? '';
97
98                    // Get sender name from contacts field
99                    $senderName = 'Customer';
100                    foreach ($value['contacts'] ?? [] as $contact) {
101                        if ($contact['wa_id'] === $senderId) {
102                            $senderName = $contact['profile']['name'] ?? 'Customer';
103                            break;
104                        }
105                    }
106
107                    $messages[] = [
108                        'sender_id' => $senderId,
109                        'sender_name' => $senderName,
110                        'message' => $text,
111                        'message_id' => $messageId,
112                    ];
113                }
114            }
115        }
116
117        return $messages;
118    }
119
120    /**
121     * Send a WhatsApp text message
122     */
123    public function sendMessage(string $to, string $text): bool
124    {
125        if (empty($this->token) || empty($this->phoneNumberId)) {
126            Log::warning('WhatsApp: Token or Phone Number ID not set.');
127
128            return false;
129        }
130
131        // WA messages max 4096 chars, split if needed
132        $chunks = str_split($text, 4000);
133
134        foreach ($chunks as $chunk) {
135            $response = Http::timeout(10)
136                ->withToken($this->token)
137                ->post("https://graph.facebook.com/v19.0/{$this->phoneNumberId}/messages", [
138                    'messaging_product' => 'whatsapp',
139                    'recipient_type' => 'individual',
140                    'to' => $to,
141                    'type' => 'text',
142                    'text' => ['body' => $chunk],
143                ]);
144
145            if (! $response->successful()) {
146                Log::error('WhatsApp Send Error: ' . $response->body());
147
148                return false;
149            }
150        }
151
152        return true;
153    }
154
155    /**
156     * Mark a message as read (shows double-blue tick)
157     */
158    public function markAsRead(string $messageId): void
159    {
160        if (empty($this->token) || empty($this->phoneNumberId)) {
161            return;
162        }
163
164        Http::timeout(5)
165            ->withToken($this->token)
166            ->post("https://graph.facebook.com/v19.0/{$this->phoneNumberId}/messages", [
167                'messaging_product' => 'whatsapp',
168                'status' => 'read',
169                'message_id' => $messageId,
170            ]);
171    }
172}