syncIfStale(); } catch (\Throwable $exception) { Log::warning('WhatsApp template catalog refresh failed.', ['error' => $exception->getMessage()]); $catalogError = 'Meta could not be reached; showing the last successful catalog.'; } $conversationSearch = trim((string) $request->input('search')); $phoneDigits = preg_replace('/\D/', '', $conversationSearch); $conversations = WhatsAppConversation::query() ->with(['user:id,name,email,phone', 'assignedAdmin:id,name', 'latestMessage']) ->withCount(['messages as failure_count' => fn ($query) => $query->where('status', 'failed')]) ->when($conversationSearch !== '', function ($query) use ($conversationSearch, $phoneDigits) { $query->where(function ($matches) use ($conversationSearch, $phoneDigits) { $matches->whereHas('user', function ($users) use ($conversationSearch) { $users->where('name', 'like', "%{$conversationSearch}%") ->orWhere('email', 'like', "%{$conversationSearch}%") ->orWhere('phone', 'like', "%{$conversationSearch}%"); }); if (strlen($phoneDigits) >= 7) { $matches->orWhere('wa_id_hash', WhatsAppConversation::waIdHash($phoneDigits)); } }); }) ->orderByDesc('last_activity_at') ->paginate(30) ->withQueryString() ->through(fn (WhatsAppConversation $conversation) => $this->conversationData($conversation)); $selected = null; $selectedId = $request->integer('conversation'); if ($selectedId) { $conversation = WhatsAppConversation::with(['user:id,name,email,phone', 'assignedAdmin:id,name']) ->findOrFail($selectedId); $selected = $this->conversationData($conversation) + [ 'messages' => $conversation->messages() ->with('admin:id,name') ->latest('id') ->limit(200) ->get() ->reverse() ->values() ->map(fn (WhatsAppMessage $message) => [ 'id' => $message->id, 'uuid' => $message->uuid, 'direction' => $message->direction, 'type' => $message->type, 'status' => $message->status, 'body' => $message->body, 'error' => data_get($message->payload, 'error.message') ?? data_get($message->payload, 'error.error.message'), 'template_name' => data_get($message->payload, 'template') ?? data_get($message->payload, 'request.template.name'), 'media' => $this->mediaSummary($message), 'attempts' => $message->attempts, 'admin' => $message->admin?->only(['id', 'name']), 'created_at' => $message->created_at?->toIso8601String(), ]), ]; } $availableTemplates = $catalog->available(); return Inertia::render('Admin/WhatsApp/Index', [ 'conversations' => $conversations, 'selected' => $selected, 'supportTemplate' => config('whatsapp.operations.support_template'), 'allowedMimes' => $mediaPolicy->allowedMimes(), 'mediaRules' => $mediaPolicy->data(), 'conversationSearch' => $conversationSearch, 'templates' => $availableTemplates->map(fn ($template) => $catalog->data($template)), 'templateCatalogSyncedAt' => $availableTemplates ->max(fn ($template) => $template->synced_at?->toIso8601String()), 'templateCatalogError' => $catalogError, 'eligibleRecipientCount' => User::whatsappReachable()->count(), 'subjects' => Subject::orderBy('title') ->get(['id', 'title', 'profile_photo_path']) ->map(fn (Subject $subject) => [ 'id' => $subject->id, 'title' => $subject->title, 'icon' => $subject->profile_photo_url, ]), 'events' => Event::orderByDesc('id')->limit(200)->get(['id', 'title']), 'campaigns' => WhatsAppCampaign::with('admin:id,name') ->latest() ->limit(12) ->get() ->map(fn (WhatsAppCampaign $campaign) => [ 'uuid' => $campaign->uuid, 'mode' => $campaign->mode, 'status' => $campaign->status, 'template_name' => $campaign->template_name, 'template_label' => data_get($campaign->template_snapshot, 'label', $campaign->template_name), 'recipient_count' => $campaign->recipient_count, 'sent_count' => $campaign->sent_count, 'failed_count' => $campaign->failed_count, 'skipped_count' => $campaign->skipped_count, 'admin' => $campaign->admin?->only(['id', 'name']), 'created_at' => $campaign->created_at?->toIso8601String(), ]), ]); } /** * The media facts the inbox needs — never the raw payload, which holds * full request bodies. */ protected function mediaSummary(WhatsAppMessage $message): ?array { if (! in_array($message->type, ['image', 'document', 'video', 'audio', 'sticker'], true)) { return null; } $store = app(WhatsAppMediaStore::class); $descriptor = $store->descriptor($message); if (! $descriptor) { return null; } return [ 'kind' => $descriptor['kind'], 'mime_type' => $descriptor['mime_type'], 'filename' => $descriptor['filename'], 'caption' => $descriptor['caption'] ?? $message->body, 'size' => $descriptor['size'] ?? null, 'pending' => ! $descriptor['media_id'] && $message->status === 'queued', 'downloadable' => app(WhatsAppMediaPolicy::class)->isAllowed($descriptor['mime_type']) && ($descriptor['media_id'] || $store->localPath($message)), 'url' => route('admin.whatsapp.media', $message->uuid), ]; } public function claim( Request $request, WhatsAppConversation $conversation, WhatsAppBridgeClient $bridge ): RedirectResponse { $synced = $this->trySetMode($bridge, $conversation, 'human'); $conversation->update([ 'assigned_admin_id' => $request->user('admin')->id, 'mode' => 'human', 'status' => 'open', ]); return back()->with( 'success', 'Conversation claimed.'.($synced ? '' : ' (Bridge unreachable — the bot handoff will sync when it reconnects.)') ); } public function reply( Request $request, WhatsAppConversation $conversation, WhatsAppBridgeClient $bridge, WhatsAppMediaPolicy $mediaPolicy ): RedirectResponse { $files = collect($request->file('attachments', [])); if ($request->file('attachment')) { $files->prepend($request->file('attachment')); } $hasFiles = $files->isNotEmpty(); $maxKilobytes = (int) ceil(config('whatsapp.media.document_max_bytes') / 1024); $data = $request->validate([ 'body' => ['nullable', 'string', 'max:'.($hasFiles ? 1024 : 4096)], 'attachment' => [ 'nullable', 'file', 'max:'.$maxKilobytes, 'mimetypes:'.implode(',', $mediaPolicy->allowedMimes()), ], 'attachments' => ['nullable', 'array', 'max:'.$mediaPolicy->maxAttachments()], 'attachments.*' => [ 'file', 'max:'.$maxKilobytes, 'mimetypes:'.implode(',', $mediaPolicy->allowedMimes()), ], ]); $insideWindow = $conversation->last_inbound_at?->isAfter(now()->subHours(24)) ?? false; $template = (string) config('whatsapp.operations.support_template'); if (! $hasFiles && trim((string) ($data['body'] ?? '')) === '') { throw ValidationException::withMessages(['body' => 'Enter a reply or choose at least one file.']); } if ($hasFiles) { if (! $insideWindow) { return back()->withErrors([ $request->file('attachment') ? 'attachment' : 'attachments' => 'Files can only be sent while the 24-hour WhatsApp reply window is open.', ]); } return $this->queueMediaReplies( $request, $conversation, $files->values(), $mediaPolicy, $data['body'] ?? null ); } if (! $insideWindow && $template === '') { return back()->withErrors([ 'body' => 'The 24-hour WhatsApp window is closed and no approved support template is configured.', ]); } try { $result = $insideWindow ? $bridge->sendMessage($conversation, 'text', body: $data['body']) : $bridge->sendMessage($conversation, 'template', template: $template, parameters: [$data['body']]); } catch (ConnectionException|RequestException|RuntimeException $exception) { // The bridge being down used to surface as an unhandled 500 with // no explanation and no way to tell whether the message went out. Log::warning('WhatsApp reply failed to reach the bridge.', [ 'conversation_id' => $conversation->id, 'error' => $exception->getMessage(), ]); return back()->withErrors([ 'body' => 'Could not reach the WhatsApp bridge — the message was not sent. Please try again.', ]); } WhatsAppMessage::firstOrCreate( // A non-standard bridge response must not blow up on a missing // uuid; a local one still lets the status events reconcile later // by meta_message_id. ['uuid' => $result['uuid'] ?? (string) Str::uuid()], [ 'whatsapp_conversation_id' => $conversation->id, 'admin_id' => $request->user('admin')->id, 'direction' => 'outbound', 'type' => $insideWindow ? 'text' : 'template', 'status' => 'queued', 'body' => $data['body'], ] ); $conversation->update([ 'assigned_admin_id' => $conversation->assigned_admin_id ?? $request->user('admin')->id, 'mode' => 'human', 'status' => 'open', 'last_activity_at' => now(), ]); return back()->with('success', 'Reply queued.'); } public function resolve( WhatsAppConversation $conversation, WhatsAppBridgeClient $bridge ): RedirectResponse { $synced = $this->trySetMode($bridge, $conversation, 'bot'); $conversation->update([ 'mode' => 'bot', 'status' => 'resolved', 'assigned_admin_id' => null, 'consecutive_failures' => 0, 'last_error' => null, ]); return back()->with( 'success', 'Conversation resolved and bot resumed.'.($synced ? '' : ' (Bridge unreachable — will sync when it reconnects.)') ); } public function resume( WhatsAppConversation $conversation, WhatsAppBridgeClient $bridge ): RedirectResponse { $synced = $this->trySetMode($bridge, $conversation, 'bot'); $conversation->update([ 'mode' => 'bot', 'status' => 'open', 'consecutive_failures' => 0, 'last_error' => null, ]); return back()->with( 'success', 'Bot resumed.'.($synced ? '' : ' (Bridge unreachable — will sync when it reconnects.)') ); } protected function queueMediaReplies( Request $request, WhatsAppConversation $conversation, $files, WhatsAppMediaPolicy $mediaPolicy, ?string $caption ): RedirectResponse { if ($files->count() > $mediaPolicy->maxAttachments()) { throw ValidationException::withMessages([ 'attachments' => 'You can send up to '.$mediaPolicy->maxAttachments().' files at once.', ]); } $totalBytes = 0; foreach ($files as $index => $file) { if ($error = $mediaPolicy->errorFor($file)) { throw ValidationException::withMessages(["attachments.{$index}" => $error]); } $totalBytes += $file->getSize(); } if ($totalBytes > $mediaPolicy->batchMaxBytes()) { throw ValidationException::withMessages([ 'attachments' => 'The selected files exceed the '.($mediaPolicy->batchMaxBytes() / 1024 / 1024).' MB combined limit.', ]); } $storedPaths = []; $messageIds = []; $correlationId = (string) Str::uuid(); try { DB::transaction(function () use ($request, $conversation, $files, $mediaPolicy, $caption, $correlationId, &$storedPaths, &$messageIds) { foreach ($files as $index => $file) { $uuid = (string) Str::uuid(); $path = $file->storeAs('whatsapp-outbound/'.$uuid, (string) Str::uuid(), 'local'); $storedPaths[] = $path; $mime = $file->getMimeType(); $filename = Str::limit(basename($file->getClientOriginalName()), 255, ''); $message = WhatsAppMessage::create([ 'uuid' => $uuid, 'whatsapp_conversation_id' => $conversation->id, 'admin_id' => $request->user('admin')->id, 'correlation_id' => $correlationId, 'direction' => 'outbound', 'type' => $mediaPolicy->isImage($mime) ? 'image' : 'document', 'status' => 'queued', 'body' => $index === 0 ? $caption : null, 'media_metadata' => [ 'outbound_upload' => [ 'disk' => 'local', 'path' => $path, 'filename' => $filename, 'mime_type' => $mime, 'size' => $file->getSize(), ], ], ]); $messageIds[] = $message->id; } }); } catch (\Throwable $exception) { foreach ($storedPaths as $path) { Storage::disk('local')->delete($path); Storage::disk('local')->deleteDirectory(dirname($path)); } throw $exception; } $conversation->update([ 'assigned_admin_id' => $conversation->assigned_admin_id ?? $request->user('admin')->id, 'mode' => 'human', 'status' => 'open', 'last_activity_at' => now(), ]); Bus::chain(array_map( fn (int $messageId) => new SendWhatsAppConversationMedia($messageId), $messageIds ))->onQueue('whatsapp')->dispatch(); return back()->with('success', count($messageIds).' file(s) queued.'); } /** * The bridge being down must degrade to a note, not a 500 — the local * mode change still applies and the bridge resyncs on its next event. */ protected function trySetMode( WhatsAppBridgeClient $bridge, WhatsAppConversation $conversation, string $mode ): bool { try { $bridge->setMode($conversation, $mode); return true; } catch (ConnectionException|RequestException|RuntimeException $exception) { Log::warning('WhatsApp bridge mode change failed.', [ 'conversation_id' => $conversation->id, 'mode' => $mode, 'error' => $exception->getMessage(), ]); return false; } } protected function conversationData(WhatsAppConversation $conversation): array { $windowExpiresAt = $conversation->last_inbound_at?->copy()->addHours(24); $windowOpen = $windowExpiresAt?->isFuture() ?? false; return [ 'id' => $conversation->id, 'wa_id' => $conversation->wa_id, 'mode' => $conversation->mode, 'status' => $conversation->status, 'consecutive_failures' => $conversation->consecutive_failures, 'failure_count' => $conversation->failure_count ?? null, 'last_error' => $conversation->last_error, 'last_inbound_at' => $conversation->last_inbound_at?->toIso8601String(), 'last_activity_at' => $conversation->last_activity_at?->toIso8601String(), 'outside_window' => ! $windowOpen, 'window_status' => $windowOpen ? 'open' : 'closed', 'window_expires_at' => $windowExpiresAt?->toIso8601String(), 'window_remaining_seconds' => $windowOpen ? max(0, $windowExpiresAt->timestamp - now()->timestamp) : 0, 'user' => $conversation->user?->only(['id', 'name', 'email', 'phone']), 'assigned_admin' => $conversation->assignedAdmin?->only(['id', 'name']), 'last_message' => $this->lastMessagePreview($conversation), ]; } protected function lastMessagePreview(WhatsAppConversation $conversation): ?array { $message = $conversation->relationLoaded('latestMessage') ? $conversation->latestMessage : null; if (! $message) { return null; } return [ 'type' => $message->type, 'direction' => $message->direction, 'snippet' => Str::limit((string) $message->body, 60), ]; } }