Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
5.81% |
9 / 155 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
| ChatbotController | |
5.81% |
9 / 155 |
|
0.00% |
0 / 8 |
1377.16 | |
0.00% |
0 / 1 |
| verifyFacebook | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| handleFacebook | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
20 | |||
| verifyWhatsApp | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| handleWhatsApp | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
20 | |||
| conversations | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
8.43 | |||
| updateStatus | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| sendReply | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
| processOrderConfirmation | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
240 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Controllers; |
| 4 | |
| 5 | use App\Models\admin\Product; |
| 6 | use App\Models\admin\Stock; |
| 7 | use App\Models\ChatConversation; |
| 8 | use App\Models\ChatMessage; |
| 9 | use App\Models\Customer; |
| 10 | use App\Models\Order; |
| 11 | use App\Models\OrderDetails; |
| 12 | use App\Models\SocialSetting; |
| 13 | use App\Service\AiChatService; |
| 14 | use App\Service\FacebookMessengerService; |
| 15 | use App\Service\WhatsAppService; |
| 16 | use Illuminate\Http\Request; |
| 17 | use Illuminate\Support\Facades\Log; |
| 18 | |
| 19 | class 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 | } |