$ticket->id, 'title' => $ticket->title, 'status' => $ticket->status ?: 'new', 'priority' => self::normalizePriority($ticket->priority), 'client_priority' => $ticket->client_priority ?: 'normal', 'source' => $ticket->source, 'response_count' => (int) $ticket->response_count, 'created_at' => self::toIso8601($ticket->created_at), 'updated_at' => self::toIso8601($ticket->updated_at), 'waiting_since' => self::toIso8601($ticket->waiting_since), 'resolved_at' => self::toIso8601($ticket->resolved_at), ]; if ($ticket->relationLoaded('customer') && $ticket->customer) { $data['customer'] = self::formatPersonSummary($ticket->customer); } if ($ticket->relationLoaded('agent') && $ticket->agent) { $data['agent'] = self::formatPersonSummary($ticket->agent); } if ($ticket->relationLoaded('product') && $ticket->product) { $data['product'] = [ 'id' => $ticket->product->id, 'title' => $ticket->product->title, ]; } if ($ticket->relationLoaded('mailbox') && $ticket->mailbox) { $data['mailbox'] = [ 'id' => $ticket->mailbox->id, 'name' => $ticket->mailbox->name, ]; } if ($ticket->relationLoaded('tags')) { $data['tags'] = $ticket->tags->map(function ($tag) { return ['id' => $tag->id, 'title' => $tag->title]; })->toArray(); } $data['content'] = self::htmlToText($ticket->content); return $data; } public static function formatTicketList($paginated, $customerMeta = []) { // Accepts either a WPFluent paginator (page/per_page mode) or a plain // collection/array (keyset cursor mode, which fetches rows directly). $items = is_object($paginated) && method_exists($paginated, 'items') ? $paginated->items() : $paginated; if (!is_iterable($items)) { $type = is_object($items) ? get_class($items) : gettype($items); throw new \Exception('MCPHelper::formatTicketList() expects a paginator, Collection, or array of tickets, got ' . $type); } $tickets = []; foreach ($items as $ticket) { $item = [ 'id' => $ticket->id, 'title' => $ticket->title, 'preview' => self::preview($ticket->content), 'status' => $ticket->status ?: 'new', 'priority' => self::normalizePriority($ticket->priority), 'client_priority' => $ticket->client_priority ?: 'normal', 'response_count' => (int) $ticket->response_count, 'last_reply_by' => $ticket->last_reply_by, 'created_at' => self::toIso8601($ticket->created_at), 'waiting_since' => self::toIso8601($ticket->waiting_since), ]; if ($ticket->relationLoaded('customer') && $ticket->customer) { $item['customer'] = self::formatPersonSummary($ticket->customer); } if ($ticket->relationLoaded('agent') && $ticket->agent) { $item['agent'] = self::formatPersonSummary($ticket->agent); } if ($ticket->relationLoaded('product') && $ticket->product) { $item['product'] = $ticket->product->title; } if ($ticket->relationLoaded('tags')) { $item['tags'] = $ticket->tags->pluck('title')->toArray(); } // Optional integration-provided customer one-liner (gated upstream by fst_sensitive_data). if ($ticket->customer_id && isset($customerMeta[$ticket->customer_id])) { $item['customer_summary'] = $customerMeta[$ticket->customer_id]; } $tickets[] = $item; } return $tickets; } /** * Wrap a tool response in a consistent envelope. * * Every tool response has: * summary — one-line the agent can quote ("12 tickets found, page 1 of 3") * data — the actual payload * meta — schema_version, generated_at, plus any caller-supplied keys (e.g. paging) */ public static function envelope($summary, $data, $meta = []) { return [ 'summary' => $summary, 'data' => $data, 'meta' => array_merge(['schema_version' => 1, 'generated_at' => gmdate('c')], $meta), ]; } /** * Extract and clamp page/per_page from $params. * Returns ['page' => int, 'per_page' => int]. */ public static function pagination($params, $default = 15, $max = null) { $max = min($max ?? self::MAX_PER_PAGE, self::HARD_MAX_PER_PAGE); return [ 'page' => max((int) ($params['page'] ?? 1), 1), 'per_page' => min(max((int) ($params['per_page'] ?? $default), 1), $max), ]; } /** * Build the paging meta block from a WPFluent paginator. * Merge this into the $meta arg of envelope(). */ public static function pagingMeta($paginator) { return [ 'paging' => [ 'current' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'pages' => $paginator->lastPage(), 'has_more' => $paginator->hasMorePages(), ], ]; } public static function formatResponseThread($responses) { $thread = []; foreach ($responses as $response) { $entry = [ 'id' => $response->id, 'type' => $response->conversation_type, 'content' => self::htmlToText($response->content), 'source' => $response->source, 'created_at' => self::toIso8601($response->created_at), ]; if ($response->relationLoaded('person') && $response->person) { $entry['person'] = [ 'name' => self::personName($response->person), 'type' => $response->person->person_type, ]; } $thread[] = $entry; } return $thread; } public static function formatCustomerForMCP($customer) { return [ 'id' => $customer->id, 'first_name' => $customer->first_name, 'last_name' => $customer->last_name, 'email' => $customer->email, 'status' => $customer->status, 'city' => $customer->city, 'state' => $customer->state, 'country' => $customer->country, 'created_at' => self::toIso8601($customer->created_at), ]; } public static function personName($model) { if (!$model) { return null; } $name = trim(($model->first_name ?? '') . ' ' . ($model->last_name ?? '')); return $name !== '' ? $name : null; } public static function formatPersonSummary($person) { return [ 'id' => $person->id, 'name' => self::personName($person), 'email' => $person->email, ]; } public static function normalizePriority($priority) { if (!$priority || $priority === '') { return 'normal'; } $aliases = [ 'low' => 'normal', 'high' => 'critical', ]; $normalized = strtolower(trim($priority)); $normalized = $aliases[$normalized] ?? $normalized; $valid = ['normal', 'medium', 'critical']; return in_array($normalized, $valid, true) ? $normalized : 'normal'; } public static function toIso8601($value) { if (!$value) { return null; } if ($value instanceof \DateTimeInterface) { return $value->format('c'); } try { if (is_object($value) && isset($value->date)) { // WPFluent sometimes returns a DateTimeImmutable-style object. $str = (string) $value->date; if (self::isZeroDate($str)) { return null; } $dt = new \DateTime($str, new \DateTimeZone($value->timezone ?? 'UTC')); if ((int) $dt->format('Y') < 1) { return null; } return $dt->format('c'); } if (is_string($value)) { if (self::isZeroDate($value)) { return null; } $dt = new \DateTime($value); if ((int) $dt->format('Y') < 1) { return null; } return $dt->format('c'); } } catch (\Exception $e) { // Malformed date string — return null rather than throwing. } return null; } /** True for MySQL zero-dates that PHP's DateTime constructor would misparse. */ private static function isZeroDate($str) { return $str === '' || strpos($str, '0000-00-00') === 0; } public static function formatExtraWidgets($widgets) { $formatted = []; foreach ($widgets as $key => $widget) { if (!empty($widget['orders'])) { $orders = []; foreach ($widget['orders'] as $order) { $item = [ 'order_id' => $order['id'] ?? $order['order_id'] ?? null, 'status' => $order['status'] ?? '', 'total' => $order['total'] ?? $order['amount'] ?? '', 'date' => $order['date'] ?? $order['date_created'] ?? '', ]; $orders[] = array_filter($item); } $formatted[$key] = [ 'title' => $widget['title'] ?? $key, 'orders' => $orders, ]; } elseif (!empty($widget['products'])) { $products = []; foreach ($widget['products'] as $product) { $item = [ 'name' => $product['title'] ?? $product['name'] ?? '', 'status' => $product['status'] ?? '', 'price' => $product['price'] ?? '', ]; $products[] = array_filter($item); } $entry = [ 'title' => $widget['title'] ?? $key, 'products' => $products, ]; if (!empty($widget['summary'])) { $entry['summary'] = $widget['summary']; } $formatted[$key] = $entry; } else { $skipKeys = ['title', 'header', 'render', 'content_type']; $data = array_diff_key($widget, array_flip($skipKeys)); if (isset($data['body_html'])) { // Raw dashboard HTML (often with an inline