validate([ 'version' => ['required', 'in:v1'], 'event_uuid' => ['required', 'uuid'], 'type' => ['required', 'string', 'max:48'], 'occurred_at' => ['required', 'date'], 'conversation' => ['required', 'array'], 'conversation.wa_id' => ['required', 'string', 'max:32'], 'conversation.correlation_id' => ['nullable', 'uuid'], 'message' => ['nullable', 'array'], 'message.uuid' => ['nullable', 'uuid'], 'message.meta_message_id' => ['nullable', 'string', 'max:255'], 'message.direction' => ['nullable', 'in:inbound,outbound'], 'message.type' => ['nullable', 'string', 'max:32'], 'message.status' => ['nullable', 'string', 'max:24'], 'message.body' => ['nullable', 'string'], 'message.payload' => ['nullable', 'array'], 'message.media_metadata' => ['nullable', 'array'], 'message.attempts' => ['nullable', 'integer', 'min:0'], 'status' => ['nullable', 'string', 'max:24'], 'payload' => ['nullable', 'array'], ]); if ($existing = WhatsAppServiceEvent::where('event_uuid', $data['event_uuid'])->first()) { return response()->json(array_merge($existing->result, ['duplicate' => true])); } try { $result = DB::transaction(fn () => $this->record($data)); } catch (UniqueConstraintViolationException) { $existing = WhatsAppServiceEvent::where('event_uuid', $data['event_uuid'])->firstOrFail(); return response()->json(array_merge($existing->result, ['duplicate' => true])); } return response()->json($result, 202); } protected function record(array $data): array { $waId = $data['conversation']['wa_id']; $e164 = PhoneNumber::toE164($waId); $user = $e164 ? User::whereIn('phone', PhoneNumber::variants($e164))->orderBy('id')->first() : null; $conversation = WhatsAppConversation::forWaId($waId, $user?->id); $message = $this->recordMessage($conversation, $data); $this->updateConversation($conversation, $data); $result = [ 'ok' => true, 'event_uuid' => $data['event_uuid'], 'conversation_id' => $conversation->id, 'message_id' => $message?->id, 'mode' => $conversation->fresh()->mode, 'duplicate' => false, ]; WhatsAppServiceEvent::create([ 'event_uuid' => $data['event_uuid'], 'whatsapp_conversation_id' => $conversation->id, 'whatsapp_message_id' => $message?->id, 'type' => $data['type'], 'status' => $data['status'] ?? data_get($data, 'message.status'), 'occurred_at' => $data['occurred_at'], 'payload' => $data['payload'] ?? [], 'result' => $result, ]); return $result; } protected function recordMessage(WhatsAppConversation $conversation, array $data): ?WhatsAppMessage { $incoming = $data['message'] ?? null; if (! $incoming) { return null; } $message = ! empty($incoming['uuid']) ? WhatsAppMessage::where('uuid', $incoming['uuid'])->first() : null; if (! $message && ! empty($incoming['meta_message_id'])) { $message = WhatsAppMessage::where('meta_message_id', $incoming['meta_message_id'])->first(); } // A status event for a message we never recorded carries no body (the // bridge sends a synthetic stub hardcoded to type 'text'). Creating a // row from it produced the "[text]" ghosts in the admin inbox — the // raw event is already preserved in whatsapp_service_events, so a // status with nothing to update is dropped, not materialized. if (! $message && $data['type'] === 'message.status') { return null; } $message ??= new WhatsAppMessage([ 'uuid' => $incoming['uuid'] ?? (string) Str::uuid(), 'whatsapp_conversation_id' => $conversation->id, ]); $incomingStatus = $incoming['status'] ?? $data['status'] ?? null; $message->fill(array_filter([ 'meta_message_id' => $incoming['meta_message_id'] ?? null, 'correlation_id' => $data['conversation']['correlation_id'] ?? null, 'direction' => $incoming['direction'] ?? null, 'type' => $incoming['type'] ?? null, 'status' => $this->laddered($message->status, $incomingStatus), 'body' => array_key_exists('body', $incoming) ? $incoming['body'] : null, 'payload' => $incoming['payload'] ?? null, 'media_metadata' => $incoming['media_metadata'] ?? null, 'attempts' => $incoming['attempts'] ?? null, ], fn ($value) => $value !== null)); // Keyed off the incoming status, not the (possibly ladder-guarded) // stored one: a late `delivered` after `read` must not change the // status but should still fill delivered_at. $timestampField = match ($incomingStatus) { 'accepted', 'sent' => 'accepted_at', 'delivered' => 'delivered_at', 'read' => 'read_at', 'failed', 'unknown' => 'failed_at', default => null, }; if ($timestampField && ! $message->{$timestampField}) { $message->{$timestampField} = $data['occurred_at']; } $message->save(); return $message; } /** * Meta's status webhooks arrive out of order; a late `delivered` must * never demote a message that is already `read`. Statuses outside the * ladder (failed, unknown) are terminal signals and always apply. */ protected function laddered(?string $current, ?string $incoming): ?string { if ($incoming === null) { return null; } $rank = ['queued' => 0, 'accepted' => 1, 'sent' => 1, 'delivered' => 2, 'read' => 3]; if (isset($rank[$current], $rank[$incoming]) && $rank[$incoming] < $rank[$current]) { return null; } return $incoming; } protected function updateConversation(WhatsAppConversation $conversation, array $data): void { $type = $data['type']; $updates = ['last_activity_at' => $data['occurred_at']]; if (str_starts_with($type, 'inbound.')) { $updates['last_inbound_at'] = $data['occurred_at']; } if (str_starts_with($type, 'outbound.') || $type === 'message.status') { $updates['last_outbound_at'] = $data['occurred_at']; } if (in_array($type, ['agent.failed', 'tool.failed', 'outbound.failed', 'outbound.unknown'], true)) { $updates['consecutive_failures'] = $conversation->consecutive_failures + 1; $updates['last_error'] = (string) data_get($data, 'payload.error', $data['status'] ?? $type); if ($updates['consecutive_failures'] >= 2) { $updates['mode'] = 'human'; } } elseif (in_array($type, ['inbound.processed', 'outbound.accepted'], true)) { $updates['consecutive_failures'] = 0; $updates['last_error'] = null; } if ($type === 'conversation.escalated') { $updates['mode'] = 'human'; $updates['status'] = 'open'; } elseif ($type === 'conversation.bot_resumed') { $updates['mode'] = 'bot'; $updates['status'] = 'open'; $updates['consecutive_failures'] = 0; } $conversation->update($updates); } }