Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.81% covered (danger)
5.81%
9 / 155
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatbotController
5.81% covered (danger)
5.81%
9 / 155
0.00% covered (danger)
0.00%
0 / 8
1377.16
0.00% covered (danger)
0.00%
0 / 1
 verifyFacebook
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 handleFacebook
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 verifyWhatsApp
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 handleWhatsApp
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 conversations
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
8.43
 updateStatus
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 sendReply
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 processOrderConfirmation
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
240
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\admin\Product;
6use App\Models\admin\Stock;
7use App\Models\ChatConversation;
8use App\Models\ChatMessage;
9use App\Models\Customer;
10use App\Models\Order;
11use App\Models\OrderDetails;
12use App\Models\SocialSetting;
13use App\Service\AiChatService;
14use App\Service\FacebookMessengerService;
15use App\Service\WhatsAppService;
16use Illuminate\Http\Request;
17use Illuminate\Support\Facades\Log;
18
19class ChatbotController extends Controller
20{
21    // ═══════════════════════════════════════════
22    //  FACEBOOK MESSENGER WEBHOOK
23    // ═══════════════════════════════════════════
24
25    /**
26     * Facebook: Verify webhook (GET)
27     */
28    public function verifyFacebook(Request $request)
29    {
30        $fb = new FacebookMessengerService;
31        $challenge = $fb->verifyWebhook($request->all());
32
33        if ($challenge !== null) {
34            return response($challenge, 200);
35        }
36
37        return response('Forbidden', 403);
38    }
39
40    /**
41     * Facebook: Receive messages (POST)
42     */
43    public function handleFacebook(Request $request)
44    {
45        $fb = new FacebookMessengerService;
46
47        // SECURITY FIX: verify this POST actually came from Facebook before doing
48        // anything with it. Previously anyone could POST a forged payload straight
49        // to this URL and have the bot "confirm" fake orders into the database.
50        if (! $fb->verifySignature($request->getContent(), $request->header('X-Hub-Signature-256'))) {
51            Log::warning('Facebook webhook: invalid signature, request rejected.');
52
53            return response('Forbidden', 403);
54        }
55
56        // Always respond 200 immediately (Facebook requires fast response)
57        $payload = $request->json()->all();
58        Log::info('FB Webhook Payload', $payload);
59
60        if (! SocialSetting::getSetting('fb_messenger_enabled', 0)) {
61            return response('ok', 200);
62        }
63
64        $ai = new AiChatService;
65
66        $messages = $fb->parseIncomingMessages($payload);
67
68        foreach ($messages as $msg) {
69            // Show typing indicator (feels human)
70            $fb->sendTypingOn($msg['sender_id']);
71
72            // Get/create conversation
73            $conversation = ChatConversation::getOrCreate('facebook', $msg['sender_id'], $msg['sender_name']);
74
75            // Save user message to DB
76            ChatMessage::create([
77                'conversation_id' => $conversation->id,
78                'role' => 'user',
79                'message' => $msg['message'],
80            ]);
81
82            // Get AI history
83            $history = $conversation->getAiHistory();
84            // Remove the last user message (already appended inside generateReply)
85            array_pop($history);
86
87            // Generate AI reply
88            $reply = $ai->generateReply($history, $msg['message']);
89
90            // Check if AI wants to confirm an order
91            $reply = $this->processOrderConfirmation($reply, $conversation, 'facebook', $msg['sender_id']);
92
93            // Save AI reply to DB
94            ChatMessage::create([
95                'conversation_id' => $conversation->id,
96                'role' => 'assistant',
97                'message' => $reply,
98            ]);
99
100            // Send reply to Facebook
101            $fb->sendMessage($msg['sender_id'], $reply);
102        }
103
104        return response('ok', 200);
105    }
106
107    // ═══════════════════════════════════════════
108    //  WHATSAPP WEBHOOK
109    // ═══════════════════════════════════════════
110
111    /**
112     * WhatsApp: Verify webhook (GET)
113     */
114    public function verifyWhatsApp(Request $request)
115    {
116        $wa = new WhatsAppService;
117        $challenge = $wa->verifyWebhook($request->all());
118
119        if ($challenge !== null) {
120            return response($challenge, 200);
121        }
122
123        return response('Forbidden', 403);
124    }
125
126    /**
127     * WhatsApp: Receive messages (POST)
128     */
129    public function handleWhatsApp(Request $request)
130    {
131        $wa = new WhatsAppService;
132
133        // SECURITY FIX: verify this POST actually came from Meta before processing it
134        // (same reasoning as the Facebook webhook above).
135        if (! $wa->verifySignature($request->getContent(), $request->header('X-Hub-Signature-256'))) {
136            Log::warning('WhatsApp webhook: invalid signature, request rejected.');
137
138            return response('Forbidden', 403);
139        }
140
141        $payload = $request->json()->all();
142        Log::info('WA Webhook Payload', $payload);
143
144        if (! SocialSetting::getSetting('wa_enabled', 0)) {
145            return response('ok', 200);
146        }
147
148        $ai = new AiChatService;
149
150        $messages = $wa->parseIncomingMessages($payload);
151
152        foreach ($messages as $msg) {
153            // Mark as read
154            $wa->markAsRead($msg['message_id']);
155
156            // Get/create conversation
157            $conversation = ChatConversation::getOrCreate('whatsapp', $msg['sender_id'], $msg['sender_name']);
158
159            // Save user message
160            ChatMessage::create([
161                'conversation_id' => $conversation->id,
162                'role' => 'user',
163                'message' => $msg['message'],
164            ]);
165
166            // Get history (without last message, generateReply adds it)
167            $history = $conversation->getAiHistory();
168            array_pop($history);
169
170            // Generate AI reply
171            $reply = $ai->generateReply($history, $msg['message']);
172
173            // Check if AI wants to confirm an order
174            $reply = $this->processOrderConfirmation($reply, $conversation, 'whatsapp', $msg['sender_id']);
175
176            // Save AI reply
177            ChatMessage::create([
178                'conversation_id' => $conversation->id,
179                'role' => 'assistant',
180                'message' => $reply,
181            ]);
182
183            // Send reply
184            $wa->sendMessage($msg['sender_id'], $reply);
185        }
186
187        return response('ok', 200);
188    }
189
190    // ═══════════════════════════════════════════
191    //  ADMIN PANEL: Conversations
192    // ═══════════════════════════════════════════
193
194    /**
195     * Admin: Unified 3-pane inbox — conversation list (left/middle) + the
196     * selected conversation's full thread (right), all on one page/route.
197     * {$id} is optional: when present, that conversation (with its
198     * messages) is eager-loaded and handed to the view as $selected.
199     */
200    public function conversations(Request $request, ?int $id = null)
201    {
202        $query = ChatConversation::with(['messages' => fn ($q) => $q->latest()->limit(1)])
203            ->orderBy('updated_at', 'desc');
204
205        if ($request->has('platform') && $request->platform !== 'all') {
206            $query->where('platform', $request->platform);
207        }
208        if ($request->has('status') && $request->status !== 'all') {
209            $query->where('status', $request->status);
210        }
211        if ($request->filled('q')) {
212            $query->where('sender_name', 'LIKE', '%' . $request->q . '%');
213        }
214
215        $conversations = $query->paginate(20)->withQueryString();
216
217        $selected = null;
218        if ($id) {
219            $selected = ChatConversation::with('messages')->find($id);
220        }
221
222        return view('admin.chatbot.conversations', compact('conversations', 'selected'));
223    }
224
225    /**
226     * Admin: Close/update conversation status
227     */
228    public function updateStatus(Request $request, int $id)
229    {
230        $conversation = ChatConversation::findOrFail($id);
231        $conversation->update(['status' => $request->status]);
232
233        return redirect()->back()->with('success', 'Status আপডেট হয়েছে।');
234    }
235
236    /**
237     * Admin: send an outbound reply directly from the inbox thread panel.
238     * Uses the same FacebookMessengerService/WhatsAppService::sendMessage()
239     * the webhook handlers already use for AI replies, so this reuses the
240     * real, working send path rather than a new one. On success the sent
241     * text is stored as a normal 'assistant' ChatMessage so it renders in
242     * the thread like any other bot reply.
243     */
244    public function sendReply(Request $request, int $id)
245    {
246        $request->validate(['message' => 'required|string|max:2000']);
247
248        $conversation = ChatConversation::findOrFail($id);
249        $text = trim($request->message);
250        $sent = false;
251
252        try {
253            if ($conversation->platform === 'facebook') {
254                $sent = (new FacebookMessengerService)->sendMessage($conversation->sender_id, $text);
255            } elseif ($conversation->platform === 'whatsapp') {
256                $sent = (new WhatsAppService)->sendMessage($conversation->sender_id, $text);
257            }
258        } catch (\Throwable $e) {
259            Log::error('Admin manual reply send error: ' . $e->getMessage());
260        }
261
262        if ($sent) {
263            ChatMessage::create([
264                'conversation_id' => $conversation->id,
265                'role' => 'assistant',
266                'message' => $text,
267            ]);
268            $conversation->touch();
269
270            return redirect()->route('admin.chatbot.conversations', $id)->with('success', 'মেসেজ পাঠানো হয়েছে।');
271        }
272
273        return redirect()->route('admin.chatbot.conversations', $id)->with('error', 'মেসেজ পাঠানো যায়নি। Facebook/WhatsApp API সেটিংস চেক করুন।');
274    }
275
276    // ═══════════════════════════════════════════
277    //  HELPERS
278    // ═══════════════════════════════════════════
279
280    /**
281     * Parse ORDER_CONFIRM tag from AI reply and create a REAL order in the
282     * same tables the storefront checkout uses (orders/order_details, keyed
283     * to a real customers row and a real stock_id).
284     *
285     * BUG FIX: this used to call Order::create() with fields ('name',
286     * 'phone', 'address', 'source') that don't exist on the Order model at
287     * all — Order actually requires customer_id (a real Customer row),
288     * bar_code/qr_code (NOT NULL, must be generated), and a total. None of
289     * that was provided, so the insert always threw, was silently caught
290     * below, and — worse — the customer still saw the AI's "✅ আপনার অর্ডার
291     * নেওয়া হয়েছে" success message because only the internal tag was
292     * stripped from the reply. So customers were being told their order was
293     * placed when nothing was ever saved. Fixed to actually create the
294     * customer/order/order line item, and to only keep the "confirmed"
295     * wording when that genuinely succeeded — otherwise it tells the
296     * customer honestly that a human will follow up.
297     */
298    protected function processOrderConfirmation(string $reply, ChatConversation $conv, string $platform, string $senderId): string
299    {
300        if (! str_contains($reply, 'ORDER_CONFIRM:')) {
301            return $reply;
302        }
303
304        preg_match('/ORDER_CONFIRM:(\{.*?\})/s', $reply, $matches);
305        $cleanReply = trim(preg_replace('/ORDER_CONFIRM:\{.*?\}/s', '', $reply));
306
307        if (empty($matches[1])) {
308            return $reply;
309        }
310
311        try {
312            $data = json_decode($matches[1], true);
313            if (! is_array($data)) {
314                throw new \RuntimeException('AI sent an unparseable ORDER_CONFIRM payload: ' . $matches[1]);
315            }
316
317            $name = trim((string) ($data['name'] ?? '')) ?: ($conv->sender_name ?: 'Customer');
318            $phone = trim((string) ($data['phone'] ?? '')) ?: $senderId;
319            $address = trim((string) ($data['address'] ?? ''));
320            $productName = trim((string) ($data['product'] ?? ''));
321            $qty = max(1, (int) ($data['quantity'] ?? 1));
322
323            // Find-or-create a real Customer, deduped by phone number, so
324            // repeat chatbot customers don't create a duplicate row each time.
325            $customer = Customer::firstOrNew(['phone' => $phone]);
326            $customer->name = $name;
327            if ($address !== '') {
328                $customer->address = $address;
329            }
330            $customer->save();
331
332            // Best-effort match the product name the AI collected to a real
333            // sellable Stock row (same source the storefront checkout uses).
334            $stock = null;
335            $price = 0;
336            if ($productName !== '') {
337                $product = Product::where('name', 'LIKE', '%' . $productName . '%')->first();
338                if ($product) {
339                    $stock = Stock::where('product_id', $product->id)->first();
340                    $price = (float) ($stock->selling_price ?? $product->selling_price ?? $product->price ?? 0);
341                }
342            }
343
344            $referenceNumber = 'ORD-' . date('Ymd') . '-' . strtoupper(substr(uniqid(), -5)) . '-CHAT';
345
346            $order = new Order;
347            $order->customer_id = $customer->id;
348            $order->bar_code = $referenceNumber;
349            $order->qr_code = $referenceNumber;
350            $order->status = 'Pending';
351            $order->payment_status = 'Due';
352            $order->total = $price * $qty;
353            $order->due = $price * $qty;
354            $order->note = "Chatbot order ({$platform}) — Product: " . ($productName ?: 'N/A') . " | Qty: {$qty}"
355                . ($stock ? '' : ' | ⚠️ product could not be auto-matched, please confirm with the customer manually.');
356            $order->save();
357
358            if ($stock) {
359                OrderDetails::create([
360                    'order_id' => $order->id,
361                    'stock_id' => $stock->id,
362                    'qty' => $qty,
363                    'price' => $price,
364                    'discount' => 0,
365                    'total' => $price * $qty,
366                ]);
367            }
368
369            // Update conversation status
370            $conv->update(['status' => 'ordered', 'order_id' => $order->id]);
371
372            return $cleanReply !== '' ? $cleanReply : '✅ আপনার অর্ডার নেওয়া হয়েছে! আমরা শীঘ্রই যোগাযোগ করব।';
373
374        } catch (\Throwable $e) {
375            Log::error('Order Confirm Error: ' . $e->getMessage());
376
377            // Don't repeat the AI's "your order is confirmed" wording when
378            // the order was NOT actually saved — that was the original bug.
379            return 'দুঃখিত, আপনার অর্ডারটি প্রসেস করতে একটু সমস্যা হচ্ছে। আমাদের একজন প্রতিনিধি শীঘ্রই সরাসরি যোগাযোগ করবেন। 🙏';
380        }
381    }
382}