Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FacebookMessengerService
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 7
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
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 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 sendMessage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 sendTypingOn
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getSenderName
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace App\Service;
4
5use App\Models\SocialSetting;
6use Illuminate\Support\Facades\Http;
7use Illuminate\Support\Facades\Log;
8
9class FacebookMessengerService
10{
11    protected string $pageAccessToken;
12
13    protected string $verifyToken;
14
15    protected string $appSecret;
16
17    public function __construct()
18    {
19        $this->pageAccessToken = (string) SocialSetting::getSetting('fb_messenger_token', '');
20        $this->verifyToken = (string) SocialSetting::getSetting('fb_verify_token', '');
21        // App secret used to sign webhook payloads (Facebook App Dashboard > Settings > Basic).
22        // Store it as a social_settings row with key 'fb_app_secret'.
23        $this->appSecret = (string) SocialSetting::getSetting('fb_app_secret', '');
24    }
25
26    /**
27     * Verify webhook challenge from Facebook
28     */
29    public function verifyWebhook(array $params): ?string
30    {
31        if (
32            isset($params['hub_mode'], $params['hub_verify_token'], $params['hub_challenge']) &&
33            $params['hub_mode'] === 'subscribe' &&
34            $params['hub_verify_token'] === $this->verifyToken
35        ) {
36            return $params['hub_challenge'];
37        }
38
39        return null;
40    }
41
42    /**
43     * SECURITY FIX: verify Facebook's X-Hub-Signature-256 header so we know a
44     * webhook POST really came from Facebook and wasn't forged by a third party
45     * (previously this wasn't checked at all — anyone could POST a fake payload
46     * straight to /api/webhook/facebook and have the bot "confirm" fake orders).
47     *
48     * If no app secret is configured yet, this logs a warning and allows the
49     * request through rather than breaking an existing integration outright —
50     * configure 'fb_app_secret' in social_settings to turn on real verification.
51     */
52    public function verifySignature(string $rawPayload, ?string $signatureHeader): bool
53    {
54        if (empty($this->appSecret)) {
55            Log::warning('Facebook: fb_app_secret not configured, skipping webhook signature verification.');
56
57            return true;
58        }
59
60        if (empty($signatureHeader) || ! str_starts_with($signatureHeader, 'sha256=')) {
61            return false;
62        }
63
64        $expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $this->appSecret);
65
66        return hash_equals($expected, $signatureHeader);
67    }
68
69    /**
70     * Parse incoming Facebook webhook payload
71     * Returns array of ['sender_id', 'message', 'sender_name']
72     */
73    public function parseIncomingMessages(array $payload): array
74    {
75        $messages = [];
76
77        if (! isset($payload['entry'])) {
78            return $messages;
79        }
80
81        foreach ($payload['entry'] as $entry) {
82            foreach ($entry['messaging'] ?? [] as $event) {
83                // Only handle text messages (skip echo, delivery, read)
84                if (! isset($event['message']['text'])) {
85                    continue;
86                }
87                if ($event['message']['is_echo'] ?? false) {
88                    continue;
89                }
90
91                $senderId = $event['sender']['id'];
92
93                // Try to get sender name from Facebook
94                $senderName = $this->getSenderName($senderId);
95
96                $messages[] = [
97                    'sender_id' => $senderId,
98                    'sender_name' => $senderName,
99                    'message' => $event['message']['text'],
100                ];
101            }
102        }
103
104        return $messages;
105    }
106
107    /**
108     * Send a text message to a Facebook user
109     */
110    public function sendMessage(string $recipientId, string $text): bool
111    {
112        if (empty($this->pageAccessToken)) {
113            Log::warning('Facebook: Page Access Token not set.');
114
115            return false;
116        }
117
118        // Split long messages (FB limit: 2000 chars)
119        $chunks = str_split($text, 1900);
120
121        foreach ($chunks as $chunk) {
122            $response = Http::timeout(10)
123                ->post("https://graph.facebook.com/v19.0/me/messages?access_token={$this->pageAccessToken}", [
124                    'recipient' => ['id' => $recipientId],
125                    'message' => ['text' => $chunk],
126                    'messaging_type' => 'RESPONSE',
127                ]);
128
129            if (! $response->successful()) {
130                Log::error('Facebook Send Error: ' . $response->body());
131
132                return false;
133            }
134        }
135
136        return true;
137    }
138
139    /**
140     * Show typing indicator (makes it feel human)
141     */
142    public function sendTypingOn(string $recipientId): void
143    {
144        if (empty($this->pageAccessToken)) {
145            return;
146        }
147
148        Http::timeout(5)->post(
149            "https://graph.facebook.com/v19.0/me/messages?access_token={$this->pageAccessToken}",
150            [
151                'recipient' => ['id' => $recipientId],
152                'sender_action' => 'typing_on',
153            ]
154        );
155    }
156
157    /**
158     * Get sender's name from Facebook Graph API
159     */
160    protected function getSenderName(string $senderId): string
161    {
162        if (empty($this->pageAccessToken)) {
163            return 'Customer';
164        }
165
166        try {
167            $response = Http::timeout(5)
168                ->get("https://graph.facebook.com/v19.0/{$senderId}", [
169                    'fields' => 'name,first_name',
170                    'access_token' => $this->pageAccessToken,
171                ]);
172
173            if ($response->successful()) {
174                return $response->json('name') ?? $response->json('first_name') ?? 'Customer';
175            }
176        } catch (\Throwable $e) {
177            Log::error('FB GetName Error: ' . $e->getMessage());
178        }
179
180        return 'Customer';
181    }
182}