isHumanInteraction()) { error_log('[WhatsApp KeywordHandler] SKIP auto-reply: conversation #' . $conversation->id . ' is in HUMAN_INTERACTION (customer_status=' . ($conversation->customer_status ?? 'null') . ')' . ' | message_id=' . $message->id . ' body="' . mb_substr($message->body ?? '', 0, 50) . '"'); return; } $body = strtolower(trim($message->body ?? '')); if (empty($body)) { error_log('[WhatsApp KeywordHandler] SKIP auto-reply: empty body | message_id=' . $message->id); return; } error_log('[WhatsApp KeywordHandler] Processing message_id=' . $message->id . ' body="' . mb_substr($body, 0, 50) . '"' . ' conversation_id=' . $conversation->id . ' customer_status=' . ($conversation->customer_status ?? 'null') . ' restaurant_id=' . $restaurantId); // Load default actions setting (single record, SettingSimpleGrouped) $defaultActions = WhatsappAction::where('restaurant_id', $restaurantId)->first(); // --------------------------------------------------------------- // 1. Check default actions (Reserve, Menu, Schedule, Human) // --------------------------------------------------------------- if ($defaultActions) { $match = self::findMatchingDefaultAction($defaultActions, $body); if ($match) { error_log('[WhatsApp KeywordHandler] MATCH default action: key=' . $match['key'] . ' lang=' . $match['lang'] . ' | message_id=' . $message->id); self::sendDefaultActionResponse( $defaultActions, $match['key'], $match['lang'], $restaurantId, $message, $conversation ); return; } } else { error_log('[WhatsApp KeywordHandler] WARNING: No WhatsappAction settings found for restaurant_id=' . $restaurantId); } // --------------------------------------------------------------- // 2. Check custom actions (SettingListing, multiple records) // --------------------------------------------------------------- $customActions = WhatsappCustomAction::where('restaurant_id', $restaurantId) ->orderBy('date_created', 'ASC') ->get() ->filter(function ($action) { return $action->whatsapp_active; }); foreach ($customActions as $customAction) { $matchedLang = self::findMatchingLanguageInConfig($customAction->whatsapp_action, $body); if ($matchedLang !== null) { error_log('[WhatsApp KeywordHandler] MATCH custom action: id=' . $customAction->id . ' lang=' . $matchedLang . ' | message_id=' . $message->id); self::sendCustomActionResponse($customAction, $matchedLang, $restaurantId, $message, $conversation); return; } } // --------------------------------------------------------------- // 3. Fallback — only if nothing else matched // --------------------------------------------------------------- if ($defaultActions && $defaultActions->isActionEnabled('action_fallback')) { $fallbackLang = self::resolveFallbackLanguage($defaultActions, $body, $restaurantId); if ($fallbackLang) { error_log('[WhatsApp KeywordHandler] MATCH fallback: lang=' . $fallbackLang . ' | message_id=' . $message->id); self::sendDefaultActionResponse( $defaultActions, 'action_fallback', $fallbackLang, $restaurantId, $message, $conversation ); return; } } error_log('[WhatsApp KeywordHandler] NO MATCH: no action matched for body="' . mb_substr($body, 0, 50) . '" | message_id=' . $message->id); } // --------------------------------------------------------------- // Matching: Default actions (except fallback) // --------------------------------------------------------------- /** * Find matching default action across all languages. * Skips fallback — that is handled separately. * * @param WhatsappAction $defaultActions * @param string $body * @return array|null ['key' => string, 'lang' => string] */ protected static function findMatchingDefaultAction(WhatsappAction $defaultActions, string $body): ?array { $keys = $defaultActions->getActionKeys(); foreach ($keys as $key) { if ($key === 'action_fallback') { continue; } if (!$defaultActions->isActionEnabled($key)) { continue; } $lang = self::findMatchingLanguageInConfig($defaultActions->{$key}, $body); if ($lang !== null) { return ['key' => $key, 'lang' => $lang]; } } return null; } // --------------------------------------------------------------- // Matching: Fallback language resolution // --------------------------------------------------------------- /** * Resolve the language for the fallback action. * * 1. Try to match fallback keywords to detect language (e.g. "hola" -> es, "hello" -> en) * 2. If no keyword match, use restaurant's default language * 3. If restaurant language has no response configured, use first language with a response * * @param WhatsappAction $defaultActions * @param string $body * @param int $restaurantId * @return string|null */ protected static function resolveFallbackLanguage(WhatsappAction $defaultActions, string $body, int $restaurantId): ?string { //ray('resolveFallbackLanguage:'.$restaurantId); $config = $defaultActions->action_fallback; if (!is_array($config) || empty($config)) { return null; } // 1. Try keyword match to detect language $matchedLang = self::findMatchingLanguageInConfig($config, $body); if ($matchedLang !== null) { return $matchedLang; } // 2. No keyword match — use restaurant's default language $restaurant = Restaurant::find($restaurantId); $restaurant_language = $restaurant->language ?? null; if ($restaurant && !empty($restaurant_language) && isset($config[$restaurant_language])) { if (self::hasResponseConfigured($config[$restaurant_language])) { return $restaurant_language; } } // 3. Use first language that has a response configured foreach ($config as $lang => $langConfig) { if (self::hasResponseConfigured($langConfig)) { return $lang; } } return null; } // --------------------------------------------------------------- // Shared matching helpers // --------------------------------------------------------------- /** * Search for a keyword match across all languages in an action config array. * Works for both default actions, custom actions, and fallback. * * @param mixed $config The per-language config array * @param string $body The lowercased message body * @return string|null The language code that matched, or null */ protected static function findMatchingLanguageInConfig($config, string $body): ?string { if (!is_array($config)) { return null; } foreach ($config as $lang => $langConfig) { if (empty($langConfig['keywords'])) { continue; } $keywords = array_map('trim', array_filter(explode(',', $langConfig['keywords']))); foreach ($keywords as $keyword) { $keywordLower = mb_strtolower(trim($keyword)); if (!empty($keywordLower) && mb_strpos($body, $keywordLower) !== false) { return $lang; } } } return null; } /** * Check if a language config has a response configured (template or custom message) * * @param array $langConfig * @return bool */ protected static function hasResponseConfigured(array $langConfig): bool { return !empty($langConfig['template_sid']) || !empty($langConfig['custom_message']); } // --------------------------------------------------------------- // Response senders // --------------------------------------------------------------- /** * Send response for a matched default action (from WhatsappAction / SettingSimpleGrouped) * * @param WhatsappAction $defaultActions * @param string $actionKey e.g. 'action_reserve', 'action_fallback' * @param string $lang * @param int $restaurantId * @param WhatsappMessage $message * @param WhatsappConversation $conversation * @return void */ protected static function sendDefaultActionResponse( WhatsappAction $defaultActions, string $actionKey, string $lang, int $restaurantId, WhatsappMessage $message, WhatsappConversation $conversation ): void { $responseType = $defaultActions->getResponseType($actionKey, $lang); if ($responseType === 'template') { $templateConfig = $defaultActions->getTemplateConfig($actionKey, $lang); if (!$templateConfig || empty($templateConfig['template_sid'])) { return; } self::sendTemplateResponse( $templateConfig, $restaurantId, $message, $conversation, 'whatsapp_action', $actionKey ); } else { $customMessage = $defaultActions->getCustomMessage($actionKey, $lang); $actionConfig = $defaultActions->getActionConfig($actionKey, $lang); $bookNowWidgetId = $actionConfig['book_now_widget_id'] ?? null; self::sendCustomMessageResponse( $customMessage, $restaurantId, $message, $conversation, $bookNowWidgetId ); } } /** * Send response for a matched custom action (from WhatsappCustomAction / SettingListing) * * @param WhatsappCustomAction $action * @param string $lang * @param int $restaurantId * @param WhatsappMessage $message * @param WhatsappConversation $conversation * @return void */ protected static function sendCustomActionResponse( WhatsappCustomAction $action, string $lang, int $restaurantId, WhatsappMessage $message, WhatsappConversation $conversation ): void { $responseType = $action->getResponseType($lang); if ($responseType === 'template') { $templateConfig = $action->getTemplateConfig($lang); if (!$templateConfig || empty($templateConfig['template_sid'])) { return; } self::sendTemplateResponse( $templateConfig, $restaurantId, $message, $conversation, 'whatsapp_custom_action', (string) $action->id ); } else { $actionConfig = $action->getActionConfig($lang); $bookNowWidgetId = $actionConfig['book_now_widget_id'] ?? null; self::sendCustomMessageResponse( $action->getCustomMessage($lang), $restaurantId, $message, $conversation, $bookNowWidgetId ); } } // --------------------------------------------------------------- // Shared response logic // --------------------------------------------------------------- /** * Send a template response. * If the conversation has a booking, delegate to the Booking model. * If no booking, send directly via WhatsappManager with restaurant-level variables. * * @param array $templateConfig * @param int $restaurantId * @param WhatsappMessage $message * @param WhatsappConversation $conversation * @param string $sourceType * @param string $sourceId * @return void */ protected static function sendTemplateResponse( array $templateConfig, int $restaurantId, WhatsappMessage $message, WhatsappConversation $conversation, string $sourceType, string $sourceId ): void { //ray('Send template response'); //ray($message); //ray($conversation); $templateSid = $templateConfig['template_sid']; $templateName = $templateConfig['template_name'] ?? ''; $fieldMapping = $templateConfig['field_mapping'] ?? []; $staticValues = $templateConfig['static_values'] ?? []; $bookNowWidgetId = $templateConfig['book_now_widget_id'] ?? null; // If there is an active booking linked, delegate to the Booking model $booking = null; if ($conversation->active_booking_id) { $booking = \Alexr\Models\Booking::find($conversation->active_booking_id); } if ($booking) { //ray('Booking found: '.$booking->id); $booking->sendWhatsappTemplateBySid( $templateSid, $templateName, $fieldMapping, null, $sourceType, $sourceId, $bookNowWidgetId, ['sender_type' => 'bot'] // Bot auto-reply: do NOT set AWAITING_REPLY ); return; } // No booking context - send directly via WhatsappManager $manager = WhatsappManager::forRestaurant($restaurantId); if (!$manager->isConfigured()) { error_log('[WhatsApp KeywordHandler] SKIP sendTemplate: WhatsApp not configured | restaurant_id=' . $restaurantId . ' message_id=' . $message->id); return; } $restaurant = Restaurant::find($restaurantId); if (!$restaurant) { error_log('[WhatsApp KeywordHandler] SKIP sendTemplate: Restaurant not found | restaurant_id=' . $restaurantId . ' message_id=' . $message->id); return; } $bookNowExtraParams = []; //ray('Conversation customer id = '.$conversation->customer_id); if ($conversation->customer_id) { $customer = Customer::find($conversation->customer_id); if ($customer && $customer->uuid) { $bookNowExtraParams['customer'] = $customer->uuid; } } $variables = RestaurantVariableResolver::resolveVariables($fieldMapping, $restaurant, $bookNowWidgetId, $bookNowExtraParams, null, $staticValues); error_log('[WhatsApp KeywordHandler] SENDING template: sid=' . $templateSid . ' name=' . $templateName . ' | to=' . $message->phone . ' message_id=' . $message->id); $result = $manager->sendTemplate( $message->phone, $templateSid, $variables, null, $conversation->customer_id, $templateName, [], // senderInfo $conversation->id // conversationId - reuse existing conversation ); error_log('[WhatsApp KeywordHandler] sendTemplate result: ' . ($result['success'] ? 'OK' : 'FAILED: ' . ($result['message'] ?? 'unknown')) . ' | message_id=' . $message->id); } /** * Send a custom text message response. * * @param string $customMessage * @param int $restaurantId * @param WhatsappMessage $message * @param WhatsappConversation $conversation * @param string|null $bookNowWidgetId * @return void */ protected static function sendCustomMessageResponse( string $customMessage, int $restaurantId, WhatsappMessage $message, WhatsappConversation $conversation, $bookNowWidgetId = null ): void { if (empty($customMessage)) { error_log('[WhatsApp KeywordHandler] SKIP sendCustomMessage: empty message | restaurant_id=' . $restaurantId . ' message_id=' . $message->id); return; } $restaurant = Restaurant::find($restaurantId); if ($restaurant) { $bookNowExtraParams = []; if ($conversation->customer_id) { $customer = Customer::find($conversation->customer_id); if ($customer && $customer->uuid) { $bookNowExtraParams['customer'] = $customer->uuid; } } $customMessage = RestaurantVariableResolver::replaceTags($customMessage, $restaurant, $bookNowWidgetId, $bookNowExtraParams); } $manager = WhatsappManager::forRestaurant($restaurantId); if (!$manager->isConfigured()) { error_log('[WhatsApp KeywordHandler] SKIP sendCustomMessage: WhatsApp not configured | restaurant_id=' . $restaurantId . ' message_id=' . $message->id); return; } error_log('[WhatsApp KeywordHandler] SENDING custom message: "' . mb_substr($customMessage, 0, 80) . '..."' . ' | to=' . $message->phone . ' message_id=' . $message->id); $result = $manager->sendMessage( $message->phone, $customMessage, $conversation->active_booking_id, $conversation->customer_id, $conversation->id ); error_log('[WhatsApp KeywordHandler] sendMessage result: ' . ($result['success'] ? 'OK' : 'FAILED: ' . ($result['message'] ?? 'unknown')) . ' | message_id=' . $message->id); } // --------------------------------------------------------------- // Public facades for external callers // Variable resolution is now centralized in RestaurantVariableResolver // --------------------------------------------------------------- /** * Public facade for resolveFallbackLanguage. * Allows external classes to resolve the fallback lang without duplicating logic. */ public static function resolveFallbackLanguagePublic( \Alexr\Settings\WhatsappAction $defaultActions, string $body, int $restaurantId ): ?string { return self::resolveFallbackLanguage($defaultActions, $body, $restaurantId); } /** * Public facade for resolveRestaurantVariables. * @deprecated Use RestaurantVariableResolver::resolveVariables() directly. */ public static function resolveRestaurantVariablesPublic( array $fieldMapping, Restaurant $restaurant, $bookNowWidgetId = null ): array { return RestaurantVariableResolver::resolveVariables($fieldMapping, $restaurant, $bookNowWidgetId); } /** * Public facade for replaceRestaurantTags. * @deprecated Use RestaurantVariableResolver::replaceTags() directly. */ public static function replaceRestaurantTagsPublic( string $message, int $restaurantId, $bookNowWidgetId = null ): string { $restaurant = Restaurant::find($restaurantId); if (!$restaurant) { return $message; } return RestaurantVariableResolver::replaceTags($message, $restaurant, $bookNowWidgetId); } } WhatsappKeywordHandler::register();